hubble-futures 0.2.13__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.
- hubble_futures/__init__.py +151 -0
- hubble_futures/aster.py +601 -0
- hubble_futures/base.py +430 -0
- hubble_futures/config.py +34 -0
- hubble_futures/function_log.py +303 -0
- hubble_futures/version.py +8 -0
- hubble_futures/weex.py +1246 -0
- hubble_futures-0.2.13.dist-info/METADATA +217 -0
- hubble_futures-0.2.13.dist-info/RECORD +11 -0
- hubble_futures-0.2.13.dist-info/WHEEL +4 -0
- hubble_futures-0.2.13.dist-info/licenses/LICENSE +21 -0
hubble_futures/weex.py
ADDED
|
@@ -0,0 +1,1246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WEEX Futures API Client
|
|
3
|
+
|
|
4
|
+
Implementation for WEEX futures trading.
|
|
5
|
+
Uses /capi/v2/ endpoints.
|
|
6
|
+
|
|
7
|
+
API Documentation: https://www.weex.com/api-doc/contract/
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
from decimal import ROUND_DOWN, ROUND_HALF_UP
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
from .base import BaseFuturesClient
|
|
23
|
+
from .config import ExchangeConfig
|
|
24
|
+
|
|
25
|
+
# Optional function logging
|
|
26
|
+
try:
|
|
27
|
+
from .function_log import finish_function_call, record_function_call
|
|
28
|
+
_FUNCTION_LOG_AVAILABLE = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
_FUNCTION_LOG_AVAILABLE = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WeexFuturesClient(BaseFuturesClient):
|
|
34
|
+
"""WEEX Futures REST API Client"""
|
|
35
|
+
|
|
36
|
+
DEFAULT_BASE_URL = "https://api-contract.weex.com"
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
api_key: str,
|
|
41
|
+
api_secret: str,
|
|
42
|
+
passphrase: str,
|
|
43
|
+
base_url: str | None = None,
|
|
44
|
+
max_retries: int = 6,
|
|
45
|
+
retry_delay: float = 1.5,
|
|
46
|
+
timeout: float = 15.0,
|
|
47
|
+
proxy_url: str | None = None,
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
Initialize WEEX Futures client.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
api_key: API key
|
|
55
|
+
api_secret: API secret
|
|
56
|
+
passphrase: API passphrase (required for WEEX)
|
|
57
|
+
base_url: API base URL
|
|
58
|
+
max_retries: Max retry attempts
|
|
59
|
+
retry_delay: Base delay for backoff
|
|
60
|
+
timeout: Request timeout
|
|
61
|
+
proxy_url: Optional proxy server URL for all requests
|
|
62
|
+
"""
|
|
63
|
+
if not passphrase:
|
|
64
|
+
raise ValueError("passphrase is required for WEEX")
|
|
65
|
+
|
|
66
|
+
self.passphrase = passphrase
|
|
67
|
+
|
|
68
|
+
super().__init__(
|
|
69
|
+
api_key=api_key,
|
|
70
|
+
api_secret=api_secret,
|
|
71
|
+
base_url=base_url or self.DEFAULT_BASE_URL,
|
|
72
|
+
max_retries=max_retries,
|
|
73
|
+
retry_delay=retry_delay,
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
proxy_url=proxy_url,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _setup_session_headers(self) -> None:
|
|
79
|
+
"""Setup WEEX-specific headers."""
|
|
80
|
+
self.session.headers.update({"Content-Type": "application/json", "locale": "en-US"})
|
|
81
|
+
|
|
82
|
+
def _get_server_time_endpoint(self) -> str:
|
|
83
|
+
"""WEEX server time endpoint."""
|
|
84
|
+
return "/capi/v2/common/time"
|
|
85
|
+
|
|
86
|
+
def _parse_server_time(self, response_data: dict) -> int: # type: ignore[type-arg]
|
|
87
|
+
"""Parse WEEX server time response."""
|
|
88
|
+
# WEEX returns {"code": "00000", "data": {"timestamp": 1234567890000}}
|
|
89
|
+
if isinstance(response_data, dict) and "data" in response_data:
|
|
90
|
+
return response_data["data"].get("timestamp", int(time.time() * 1000))
|
|
91
|
+
return int(time.time() * 1000)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def from_config(cls, config: ExchangeConfig) -> "WeexFuturesClient":
|
|
95
|
+
"""Create client from ExchangeConfig."""
|
|
96
|
+
return cls(
|
|
97
|
+
api_key=config.api_key,
|
|
98
|
+
api_secret=config.api_secret,
|
|
99
|
+
passphrase=config.passphrase,
|
|
100
|
+
base_url=config.base_url or cls.DEFAULT_BASE_URL,
|
|
101
|
+
proxy_url=config.proxy_url,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# ==================== Symbol Conversion ====================
|
|
105
|
+
|
|
106
|
+
def _to_weex_symbol(self, symbol: str) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Convert standard symbol to WEEX format.
|
|
109
|
+
BTCUSDT -> cmt_btcusdt
|
|
110
|
+
"""
|
|
111
|
+
if symbol.startswith("cmt_"):
|
|
112
|
+
return symbol
|
|
113
|
+
return f"cmt_{symbol.lower()}"
|
|
114
|
+
|
|
115
|
+
def _from_weex_symbol(self, weex_symbol: str) -> str:
|
|
116
|
+
"""
|
|
117
|
+
Convert WEEX symbol to standard format.
|
|
118
|
+
cmt_btcusdt -> BTCUSDT
|
|
119
|
+
"""
|
|
120
|
+
if weex_symbol.startswith("cmt_"):
|
|
121
|
+
return weex_symbol[4:].upper()
|
|
122
|
+
return weex_symbol.upper()
|
|
123
|
+
|
|
124
|
+
# ==================== Authentication ====================
|
|
125
|
+
|
|
126
|
+
def _generate_signature(self, params: dict) -> str: # type: ignore[type-arg]
|
|
127
|
+
"""
|
|
128
|
+
Generate request signature for WEEX.
|
|
129
|
+
|
|
130
|
+
WEEX signature: HMAC-SHA256(timestamp + method + path + body) -> Base64
|
|
131
|
+
|
|
132
|
+
Note: This is called by _request but WEEX uses a different signing approach,
|
|
133
|
+
so we override _request entirely.
|
|
134
|
+
"""
|
|
135
|
+
# This method is not used directly for WEEX
|
|
136
|
+
# Signature is generated in _request method
|
|
137
|
+
return ""
|
|
138
|
+
|
|
139
|
+
def _generate_weex_signature(self, timestamp: str, method: str, path: str, body: str = "") -> str:
|
|
140
|
+
"""
|
|
141
|
+
Generate WEEX-specific signature.
|
|
142
|
+
|
|
143
|
+
Sign string: timestamp + method + path + body
|
|
144
|
+
Algorithm: HMAC-SHA256 -> Base64
|
|
145
|
+
"""
|
|
146
|
+
sign_string = f"{timestamp}{method.upper()}{path}{body}"
|
|
147
|
+
signature = hmac.new(self.api_secret.encode("utf-8"), sign_string.encode("utf-8"), hashlib.sha256).digest()
|
|
148
|
+
return base64.b64encode(signature).decode("utf-8")
|
|
149
|
+
|
|
150
|
+
def _request(
|
|
151
|
+
self,
|
|
152
|
+
method: str,
|
|
153
|
+
endpoint: str,
|
|
154
|
+
signed: bool = False,
|
|
155
|
+
params: dict | None = None, # type: ignore[type-arg]
|
|
156
|
+
data: dict | None = None, # type: ignore[type-arg]
|
|
157
|
+
**kwargs: dict, # type: ignore[type-arg]
|
|
158
|
+
) -> dict: # type: ignore[type-arg]
|
|
159
|
+
"""
|
|
160
|
+
WEEX-specific request method.
|
|
161
|
+
|
|
162
|
+
WEEX uses:
|
|
163
|
+
- Headers for authentication (ACCESS-KEY, ACCESS-SIGN, etc.)
|
|
164
|
+
- JSON body for POST requests
|
|
165
|
+
- Query params for GET requests
|
|
166
|
+
"""
|
|
167
|
+
url = f"{self.base_url}{endpoint}"
|
|
168
|
+
|
|
169
|
+
# Record function call start (if logging is available)
|
|
170
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
171
|
+
function_name = f"{method.lower()}_{endpoint.replace('/', '_')}"
|
|
172
|
+
record_params = {"endpoint": endpoint, "method": method}
|
|
173
|
+
if params:
|
|
174
|
+
record_params.update(params)
|
|
175
|
+
if data:
|
|
176
|
+
record_params["data"] = data
|
|
177
|
+
record_function_call(function_name, record_params)
|
|
178
|
+
|
|
179
|
+
# Build query string for GET requests
|
|
180
|
+
query_string = ""
|
|
181
|
+
if params and method.upper() == "GET":
|
|
182
|
+
query_string = "?" + "&".join([f"{k}={v}" for k, v in params.items()])
|
|
183
|
+
|
|
184
|
+
# Build request body for POST requests
|
|
185
|
+
body = ""
|
|
186
|
+
if data:
|
|
187
|
+
body = json.dumps(data)
|
|
188
|
+
|
|
189
|
+
headers = dict(self.session.headers)
|
|
190
|
+
|
|
191
|
+
if signed:
|
|
192
|
+
timestamp = str(int(time.time() * 1000))
|
|
193
|
+
sign_path = endpoint + query_string
|
|
194
|
+
signature = self._generate_weex_signature(timestamp, method, sign_path, body)
|
|
195
|
+
|
|
196
|
+
headers.update(
|
|
197
|
+
{
|
|
198
|
+
"ACCESS-KEY": self.api_key,
|
|
199
|
+
"ACCESS-SIGN": signature,
|
|
200
|
+
"ACCESS-TIMESTAMP": timestamp,
|
|
201
|
+
"ACCESS-PASSPHRASE": self.passphrase,
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
# Build proxies dict for explicit passing
|
|
206
|
+
proxies = None
|
|
207
|
+
if self.proxy_url:
|
|
208
|
+
proxies = {"http": self.proxy_url, "https": self.proxy_url}
|
|
209
|
+
|
|
210
|
+
for attempt in range(self.max_retries + 1):
|
|
211
|
+
try:
|
|
212
|
+
if method.upper() == "GET":
|
|
213
|
+
response = self.session.get(
|
|
214
|
+
url, params=params, headers=headers, timeout=self.timeout, proxies=proxies
|
|
215
|
+
)
|
|
216
|
+
elif method.upper() == "POST":
|
|
217
|
+
response = self.session.post(
|
|
218
|
+
url, data=body if body else None, headers=headers, timeout=self.timeout, proxies=proxies
|
|
219
|
+
)
|
|
220
|
+
elif method.upper() == "DELETE":
|
|
221
|
+
response = self.session.delete(
|
|
222
|
+
url, params=params, headers=headers, timeout=self.timeout, proxies=proxies
|
|
223
|
+
)
|
|
224
|
+
else:
|
|
225
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
226
|
+
finish_function_call(function_name, error=f"Unsupported HTTP method: {method}")
|
|
227
|
+
raise ValueError(f"Unsupported HTTP method: {method}")
|
|
228
|
+
|
|
229
|
+
response.raise_for_status()
|
|
230
|
+
result = response.json()
|
|
231
|
+
|
|
232
|
+
# WEEX returns {code, msg, data} structure for some endpoints
|
|
233
|
+
if isinstance(result, dict) and "code" in result:
|
|
234
|
+
if result.get("code") not in ["200", "00000", 200]:
|
|
235
|
+
error_msg = f"WEEX API error: {result.get('msg', 'Unknown error')}"
|
|
236
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
237
|
+
finish_function_call(function_name, error=error_msg)
|
|
238
|
+
raise Exception(error_msg)
|
|
239
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
240
|
+
finish_function_call(function_name, {"status": "succeeded", "data": result.get("data", result)})
|
|
241
|
+
return result.get("data", result)
|
|
242
|
+
|
|
243
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
244
|
+
finish_function_call(function_name, {"status": "succeeded", "data": result})
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
except requests.exceptions.HTTPError as e:
|
|
248
|
+
resp = e.response
|
|
249
|
+
|
|
250
|
+
if resp.status_code == 429:
|
|
251
|
+
if attempt < self.max_retries:
|
|
252
|
+
delay = self.retry_delay * (2**attempt)
|
|
253
|
+
jitter = delay * 0.2 * (2 * random.random() - 1)
|
|
254
|
+
delay = delay + jitter
|
|
255
|
+
logger.warning(
|
|
256
|
+
f"Rate limit hit (429). Retry {attempt + 1}/{self.max_retries} after {delay:.2f}s"
|
|
257
|
+
)
|
|
258
|
+
time.sleep(delay)
|
|
259
|
+
continue
|
|
260
|
+
else:
|
|
261
|
+
logger.error(f"Rate limit exceeded after {self.max_retries} retries")
|
|
262
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
263
|
+
finish_function_call(function_name, error="Rate limit exceeded")
|
|
264
|
+
raise
|
|
265
|
+
|
|
266
|
+
logger.error(f"WEEX API request failed: {e}")
|
|
267
|
+
logger.error(f"Response: {resp.text}")
|
|
268
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
269
|
+
finish_function_call(function_name, error=str(e))
|
|
270
|
+
raise
|
|
271
|
+
|
|
272
|
+
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
|
|
273
|
+
err_type = "timeout" if isinstance(e, requests.exceptions.Timeout) else "connection error"
|
|
274
|
+
if attempt < self.max_retries:
|
|
275
|
+
delay = self.retry_delay * (2**attempt)
|
|
276
|
+
jitter = delay * 0.2 * (2 * random.random() - 1)
|
|
277
|
+
delay = delay + jitter
|
|
278
|
+
logger.warning(
|
|
279
|
+
f"{err_type.capitalize()}: {method} {endpoint}. Retry {attempt + 1}/{self.max_retries} after {delay:.2f}s"
|
|
280
|
+
)
|
|
281
|
+
time.sleep(delay)
|
|
282
|
+
continue
|
|
283
|
+
if isinstance(e, requests.exceptions.Timeout):
|
|
284
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
285
|
+
finish_function_call(function_name, error=f"Timeout after {self.timeout}s")
|
|
286
|
+
raise Exception(f"API request timeout after {self.timeout}s") from e
|
|
287
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
288
|
+
finish_function_call(function_name, error=f"Connection error: {e}")
|
|
289
|
+
raise Exception(f"API connection failed: {e}") from e
|
|
290
|
+
|
|
291
|
+
except Exception as e:
|
|
292
|
+
logger.error(f"Request exception: {method} {endpoint} - {e}")
|
|
293
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
294
|
+
finish_function_call(function_name, error=str(e))
|
|
295
|
+
raise
|
|
296
|
+
|
|
297
|
+
if _FUNCTION_LOG_AVAILABLE:
|
|
298
|
+
finish_function_call(function_name, error="Exhausted all retry attempts")
|
|
299
|
+
raise Exception(f"Exhausted all retry attempts for {method} {endpoint}")
|
|
300
|
+
|
|
301
|
+
# ==================== Market Data ====================
|
|
302
|
+
|
|
303
|
+
def get_klines(self, symbol: str, interval: str = "1h", limit: int = 200) -> list[dict]: # type: ignore[type-arg]
|
|
304
|
+
"""
|
|
305
|
+
Fetch candlestick data.
|
|
306
|
+
|
|
307
|
+
Converts WEEX response to Aster-compatible format.
|
|
308
|
+
"""
|
|
309
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
310
|
+
|
|
311
|
+
# WEEX uses 'granularity' instead of 'interval'
|
|
312
|
+
params = {
|
|
313
|
+
"symbol": weex_symbol,
|
|
314
|
+
"granularity": interval,
|
|
315
|
+
"limit": min(limit, 1000), # WEEX max is 1000
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
data = self._request("GET", "/capi/v2/market/candles", params=params)
|
|
319
|
+
|
|
320
|
+
# WEEX returns: [[timestamp, open, high, low, close, base_vol, quote_vol], ...]
|
|
321
|
+
klines = []
|
|
322
|
+
for k in data:
|
|
323
|
+
klines.append(
|
|
324
|
+
{
|
|
325
|
+
"open_time": int(k[0]),
|
|
326
|
+
"open": float(k[1]),
|
|
327
|
+
"high": float(k[2]),
|
|
328
|
+
"low": float(k[3]),
|
|
329
|
+
"close": float(k[4]),
|
|
330
|
+
"volume": float(k[5]),
|
|
331
|
+
"close_time": int(k[0]), # WEEX doesn't have close_time, use open_time
|
|
332
|
+
"quote_volume": float(k[6]) if len(k) > 6 else 0,
|
|
333
|
+
"trades": 0, # WEEX doesn't provide trade count
|
|
334
|
+
}
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
return klines
|
|
338
|
+
|
|
339
|
+
def get_mark_price(self, symbol: str) -> dict: # type: ignore[type-arg]
|
|
340
|
+
"""
|
|
341
|
+
Fetch mark price information.
|
|
342
|
+
|
|
343
|
+
WEEX: Mark price is in ticker, funding rate needs separate call.
|
|
344
|
+
"""
|
|
345
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
346
|
+
|
|
347
|
+
# Get ticker for mark price and index price
|
|
348
|
+
ticker = self._request("GET", "/capi/v2/market/ticker", params={"symbol": weex_symbol})
|
|
349
|
+
|
|
350
|
+
# Get current funding rate
|
|
351
|
+
try:
|
|
352
|
+
funding = self._request("GET", "/capi/v2/market/currentFundRate", params={"symbol": weex_symbol})
|
|
353
|
+
funding_rate = float(funding.get("fundingRate", 0))
|
|
354
|
+
next_funding_time = funding.get("timestamp", 0)
|
|
355
|
+
except Exception:
|
|
356
|
+
funding_rate = 0
|
|
357
|
+
next_funding_time = 0
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
"symbol": symbol,
|
|
361
|
+
"mark_price": float(ticker.get("markPrice", 0)),
|
|
362
|
+
"index_price": float(ticker.get("indexPrice", 0)),
|
|
363
|
+
"funding_rate": funding_rate,
|
|
364
|
+
"next_funding_time": next_funding_time,
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
def get_funding_rate_history(self, symbol: str, limit: int = 100) -> list[dict]: # type: ignore[type-arg]
|
|
368
|
+
"""Fetch historical funding rates."""
|
|
369
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
370
|
+
|
|
371
|
+
params = {
|
|
372
|
+
"symbol": weex_symbol,
|
|
373
|
+
"limit": min(limit, 100), # WEEX max is 100
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
data = self._request("GET", "/capi/v2/market/getHistoryFundRate", params=params)
|
|
377
|
+
|
|
378
|
+
# Convert to standardized format
|
|
379
|
+
result = []
|
|
380
|
+
for item in data:
|
|
381
|
+
result.append(
|
|
382
|
+
{
|
|
383
|
+
"symbol": symbol,
|
|
384
|
+
"funding_rate": float(item.get("fundingRate", 0)),
|
|
385
|
+
"funding_time": int(item.get("fundingTime", 0)),
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
return result
|
|
390
|
+
|
|
391
|
+
def get_open_interest(self, symbol: str) -> dict: # type: ignore[type-arg]
|
|
392
|
+
"""
|
|
393
|
+
Fetch open interest statistics.
|
|
394
|
+
|
|
395
|
+
Note: WEEX does not have a dedicated open interest endpoint.
|
|
396
|
+
This method attempts to extract open interest from ticker data.
|
|
397
|
+
"""
|
|
398
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
399
|
+
|
|
400
|
+
# Try to get from ticker endpoint (WEEX may include open interest here)
|
|
401
|
+
try:
|
|
402
|
+
data = self._request("GET", "/capi/v2/market/ticker", params={"symbol": weex_symbol})
|
|
403
|
+
open_interest = float(data.get("openInterest", 0))
|
|
404
|
+
if open_interest == 0:
|
|
405
|
+
logger.debug(f"WEEX ticker for {symbol} has no openInterest field, using default 0")
|
|
406
|
+
except Exception as e:
|
|
407
|
+
logger.warning(f"Failed to fetch open interest for {symbol}: {e}, returning 0")
|
|
408
|
+
open_interest = 0.0
|
|
409
|
+
|
|
410
|
+
return {"symbol": symbol, "open_interest": open_interest, "timestamp": int(time.time() * 1000)}
|
|
411
|
+
|
|
412
|
+
def get_ticker_24hr(self, symbol: str) -> dict: # type: ignore[type-arg]
|
|
413
|
+
"""Fetch 24-hour price change statistics."""
|
|
414
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
415
|
+
|
|
416
|
+
data = self._request("GET", "/capi/v2/market/ticker", params={"symbol": weex_symbol})
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
"symbol": symbol,
|
|
420
|
+
"lastPrice": data.get("last"),
|
|
421
|
+
"priceChange": data.get("priceChangePercent"),
|
|
422
|
+
"highPrice": data.get("high_24h"),
|
|
423
|
+
"lowPrice": data.get("low_24h"),
|
|
424
|
+
"volume": data.get("base_volume"),
|
|
425
|
+
"quoteVolume": data.get("volume_24h"),
|
|
426
|
+
"markPrice": data.get("markPrice"),
|
|
427
|
+
"indexPrice": data.get("indexPrice"),
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
def get_depth(self, symbol: str, limit: int = 20) -> dict: # type: ignore[type-arg]
|
|
431
|
+
"""Fetch orderbook depth."""
|
|
432
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
433
|
+
|
|
434
|
+
# WEEX only supports limit 15 or 200
|
|
435
|
+
weex_limit = 200 if limit > 15 else 15
|
|
436
|
+
|
|
437
|
+
data = self._request("GET", "/capi/v2/market/depth", params={"symbol": weex_symbol, "limit": weex_limit})
|
|
438
|
+
|
|
439
|
+
return {"bids": data.get("bids", []), "asks": data.get("asks", []), "timestamp": data.get("timestamp")}
|
|
440
|
+
|
|
441
|
+
# ==================== Exchange Metadata ====================
|
|
442
|
+
|
|
443
|
+
def get_exchange_info(self) -> dict: # type: ignore[type-arg]
|
|
444
|
+
"""Fetch exchange information."""
|
|
445
|
+
data = self._request("GET", "/capi/v2/market/contracts")
|
|
446
|
+
return {"symbols": data}
|
|
447
|
+
|
|
448
|
+
def get_symbol_filters(self, symbol: str, force_refresh: bool = False) -> dict: # type: ignore[type-arg]
|
|
449
|
+
"""Fetch symbol filters."""
|
|
450
|
+
if symbol in self._symbol_filters and not force_refresh:
|
|
451
|
+
return self._symbol_filters[symbol]
|
|
452
|
+
|
|
453
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
454
|
+
contracts = self._request("GET", "/capi/v2/market/contracts", params={"symbol": weex_symbol})
|
|
455
|
+
|
|
456
|
+
if not contracts:
|
|
457
|
+
raise ValueError(f"Symbol {symbol} not found")
|
|
458
|
+
|
|
459
|
+
contract = contracts[0] if isinstance(contracts, list) else contracts
|
|
460
|
+
|
|
461
|
+
# Convert WEEX format to Aster-compatible format
|
|
462
|
+
filters = {
|
|
463
|
+
"contract_type": "PERPETUAL",
|
|
464
|
+
"contract_size": float(contract.get("contract_val", 1)),
|
|
465
|
+
"contract_status": "TRADING",
|
|
466
|
+
# Precision: WEEX uses tick_size and size_increment differently
|
|
467
|
+
"price_precision": int(contract.get("tick_size", 1)),
|
|
468
|
+
"quantity_precision": int(contract.get("size_increment", 5)),
|
|
469
|
+
# Calculate tick_size and step_size from precision
|
|
470
|
+
"tick_size": 10 ** (-int(contract.get("tick_size", 1))),
|
|
471
|
+
"step_size": 10 ** (-int(contract.get("size_increment", 5))),
|
|
472
|
+
"min_qty": float(contract.get("minOrderSize", 0.0001)),
|
|
473
|
+
"max_qty": float(contract.get("maxOrderSize", 100000)),
|
|
474
|
+
"min_price": 0,
|
|
475
|
+
"max_price": float("inf"),
|
|
476
|
+
# WEEX doesn't have min_notional in contract info
|
|
477
|
+
"min_notional": 1, # Default minimum
|
|
478
|
+
"min_leverage": int(contract.get("minLeverage", 1)),
|
|
479
|
+
"max_leverage": int(contract.get("maxLeverage", 125)),
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
self._symbol_filters[symbol] = filters
|
|
483
|
+
return filters
|
|
484
|
+
|
|
485
|
+
def get_leverage_bracket(self, symbol: str | None = None, force_refresh: bool = False) -> list[dict]: # type: ignore[type-arg]
|
|
486
|
+
"""Fetch leverage bracket information."""
|
|
487
|
+
# WEEX includes leverage info in contracts endpoint
|
|
488
|
+
if symbol:
|
|
489
|
+
filters = self.get_symbol_filters(symbol, force_refresh)
|
|
490
|
+
return [
|
|
491
|
+
{
|
|
492
|
+
"symbol": symbol,
|
|
493
|
+
"brackets": [
|
|
494
|
+
{
|
|
495
|
+
"bracket": 1,
|
|
496
|
+
"initialLeverage": filters.get("max_leverage", 125),
|
|
497
|
+
"notionalCap": float("inf"),
|
|
498
|
+
"notionalFloor": 0,
|
|
499
|
+
"maintMarginRatio": 0.005,
|
|
500
|
+
}
|
|
501
|
+
],
|
|
502
|
+
}
|
|
503
|
+
]
|
|
504
|
+
return []
|
|
505
|
+
|
|
506
|
+
# ==================== Account ====================
|
|
507
|
+
|
|
508
|
+
def get_account(self) -> dict: # type: ignore[type-arg]
|
|
509
|
+
"""Fetch account information."""
|
|
510
|
+
data = self._request("GET", "/capi/v2/account/assets", signed=True)
|
|
511
|
+
|
|
512
|
+
# WEEX returns array of assets, find USDT
|
|
513
|
+
usdt_asset = None
|
|
514
|
+
for asset in data:
|
|
515
|
+
if asset.get("coinName") == "USDT":
|
|
516
|
+
usdt_asset = asset
|
|
517
|
+
break
|
|
518
|
+
|
|
519
|
+
if not usdt_asset:
|
|
520
|
+
usdt_asset = data[0] if data else {}
|
|
521
|
+
|
|
522
|
+
available = float(usdt_asset.get("available", 0))
|
|
523
|
+
equity = float(usdt_asset.get("equity", 0))
|
|
524
|
+
frozen = float(usdt_asset.get("frozen", 0))
|
|
525
|
+
unrealized_pnl = float(usdt_asset.get("unrealizePnl", 0))
|
|
526
|
+
|
|
527
|
+
# Calculate wallet balance (equity - unrealized PnL)
|
|
528
|
+
wallet_balance = equity - unrealized_pnl
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
"total_wallet_balance": wallet_balance,
|
|
532
|
+
"total_unrealized_profit": unrealized_pnl,
|
|
533
|
+
"total_margin_balance": equity,
|
|
534
|
+
"total_position_initial_margin": frozen,
|
|
535
|
+
"total_open_order_initial_margin": 0,
|
|
536
|
+
"available_balance": available,
|
|
537
|
+
"max_withdraw_amount": available,
|
|
538
|
+
"assets": data,
|
|
539
|
+
"positions": [], # Positions need separate call
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
def get_positions(self, symbol: str | None = None) -> list[dict]: # type: ignore[type-arg]
|
|
543
|
+
"""Fetch current positions."""
|
|
544
|
+
data = self._request("GET", "/capi/v2/account/position/allPosition", signed=True)
|
|
545
|
+
|
|
546
|
+
positions = []
|
|
547
|
+
for p in data:
|
|
548
|
+
# Skip empty positions
|
|
549
|
+
size = float(p.get("size", 0))
|
|
550
|
+
if size == 0:
|
|
551
|
+
continue
|
|
552
|
+
|
|
553
|
+
pos_symbol = self._from_weex_symbol(p.get("symbol", ""))
|
|
554
|
+
|
|
555
|
+
# Filter by symbol if provided
|
|
556
|
+
if symbol and pos_symbol != symbol:
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
# Convert side to position_amt (positive for LONG, negative for SHORT)
|
|
560
|
+
side = p.get("side", "").upper()
|
|
561
|
+
position_amt = size if side == "LONG" else -size
|
|
562
|
+
|
|
563
|
+
# Calculate entry price from open_value / size
|
|
564
|
+
open_value = float(p.get("open_value", 0))
|
|
565
|
+
entry_price = open_value / size if size > 0 else 0
|
|
566
|
+
|
|
567
|
+
# Get mark price from separate call if needed
|
|
568
|
+
# For now, estimate from unrealized PnL
|
|
569
|
+
unrealized_pnl = float(p.get("unrealizePnl", 0))
|
|
570
|
+
|
|
571
|
+
positions.append(
|
|
572
|
+
{
|
|
573
|
+
"symbol": pos_symbol,
|
|
574
|
+
"position_amt": position_amt,
|
|
575
|
+
"entry_price": entry_price,
|
|
576
|
+
"mark_price": 0, # Would need separate ticker call
|
|
577
|
+
"unrealized_profit": unrealized_pnl,
|
|
578
|
+
"liquidation_price": float(p.get("liquidatePrice", 0)),
|
|
579
|
+
"leverage": int(float(p.get("leverage", 1))),
|
|
580
|
+
"margin_type": "cross" if p.get("margin_mode") == "SHARED" else "isolated",
|
|
581
|
+
"isolated_margin": float(p.get("marginSize", 0)),
|
|
582
|
+
"position_side": side,
|
|
583
|
+
}
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
return positions
|
|
587
|
+
|
|
588
|
+
def get_balance(self) -> dict: # type: ignore[type-arg]
|
|
589
|
+
"""Fetch account balance summary."""
|
|
590
|
+
account = self.get_account()
|
|
591
|
+
return {
|
|
592
|
+
"available_balance": account["available_balance"],
|
|
593
|
+
"total_margin_balance": account["total_margin_balance"],
|
|
594
|
+
"total_unrealized_profit": account["total_unrealized_profit"],
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
# ==================== Trading ====================
|
|
598
|
+
|
|
599
|
+
def set_leverage(self, symbol: str, leverage: int) -> dict: # type: ignore[type-arg]
|
|
600
|
+
"""Configure leverage for a symbol."""
|
|
601
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
602
|
+
|
|
603
|
+
# WEEX requires marginMode and separate long/short leverage
|
|
604
|
+
data = {
|
|
605
|
+
"symbol": weex_symbol,
|
|
606
|
+
"marginMode": 1, # 1=Cross, 3=Isolated
|
|
607
|
+
"longLeverage": str(leverage),
|
|
608
|
+
"shortLeverage": str(leverage),
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return self._request("POST", "/capi/v2/account/leverage", signed=True, data=data)
|
|
612
|
+
|
|
613
|
+
def set_margin_type(self, symbol: str, margin_type: str = "ISOLATED") -> dict: # type: ignore[type-arg]
|
|
614
|
+
"""Configure margin mode."""
|
|
615
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
616
|
+
|
|
617
|
+
# Convert Aster margin type to WEEX
|
|
618
|
+
weex_margin_mode = 3 if margin_type.upper() == "ISOLATED" else 1
|
|
619
|
+
|
|
620
|
+
data = {"symbol": weex_symbol, "marginMode": weex_margin_mode}
|
|
621
|
+
|
|
622
|
+
return self._request("POST", "/capi/v2/account/position/changeHoldModel", signed=True, data=data)
|
|
623
|
+
|
|
624
|
+
def place_order(
|
|
625
|
+
self,
|
|
626
|
+
symbol: str,
|
|
627
|
+
side: str,
|
|
628
|
+
order_type: str = "LIMIT",
|
|
629
|
+
quantity: float | None = None,
|
|
630
|
+
price: float | None = None,
|
|
631
|
+
stop_price: float | None = None,
|
|
632
|
+
reduce_only: bool = False,
|
|
633
|
+
time_in_force: str = "GTC",
|
|
634
|
+
client_order_id: str | None = None,
|
|
635
|
+
**kwargs: dict, # type: ignore[type-arg]
|
|
636
|
+
) -> dict: # type: ignore[type-arg]
|
|
637
|
+
"""
|
|
638
|
+
Place an order.
|
|
639
|
+
|
|
640
|
+
Converts Aster-style parameters to WEEX format:
|
|
641
|
+
- side (BUY/SELL) + reduce_only -> type (1/2/3/4)
|
|
642
|
+
- order_type (LIMIT/MARKET) -> match_price (0/1)
|
|
643
|
+
|
|
644
|
+
Args:
|
|
645
|
+
margin_mode (str, optional): Margin mode - "cross" or "isolated".
|
|
646
|
+
Defaults to "cross". WEEX requires this to match account's
|
|
647
|
+
current margin mode setting.
|
|
648
|
+
"""
|
|
649
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
650
|
+
|
|
651
|
+
# Convert side + reduce_only to WEEX type
|
|
652
|
+
# 1=Open Long, 2=Open Short, 3=Close Long, 4=Close Short
|
|
653
|
+
if side.upper() == "BUY":
|
|
654
|
+
weex_type = "4" if reduce_only else "1" # Close Short or Open Long
|
|
655
|
+
else: # SELL
|
|
656
|
+
weex_type = "3" if reduce_only else "2" # Close Long or Open Short
|
|
657
|
+
|
|
658
|
+
# Convert order type to match_price
|
|
659
|
+
# 0=Limit, 1=Market
|
|
660
|
+
match_price = "1" if order_type.upper() == "MARKET" else "0"
|
|
661
|
+
|
|
662
|
+
# Convert time_in_force to order_type
|
|
663
|
+
# 0=Normal(GTC), 1=PostOnly, 2=FOK, 3=IOC
|
|
664
|
+
order_type_map = {
|
|
665
|
+
"GTC": "0",
|
|
666
|
+
"IOC": "3",
|
|
667
|
+
"FOK": "2",
|
|
668
|
+
"GTX": "1", # PostOnly
|
|
669
|
+
}
|
|
670
|
+
weex_order_type = order_type_map.get(time_in_force.upper(), "0")
|
|
671
|
+
|
|
672
|
+
# Determine marginMode from kwargs or default to Cross (1)
|
|
673
|
+
# WEEX: 1=Cross, 3=Isolated
|
|
674
|
+
margin_mode = kwargs.get("margin_mode", "cross")
|
|
675
|
+
if isinstance(margin_mode, str):
|
|
676
|
+
weex_margin_mode = "3" if margin_mode.lower() == "isolated" else "1"
|
|
677
|
+
else:
|
|
678
|
+
weex_margin_mode = str(margin_mode) if margin_mode in [1, 3] else "1"
|
|
679
|
+
|
|
680
|
+
# Build order data
|
|
681
|
+
data: dict = { # type: ignore[type-arg]
|
|
682
|
+
"symbol": weex_symbol,
|
|
683
|
+
"type": weex_type,
|
|
684
|
+
"match_price": match_price,
|
|
685
|
+
"order_type": weex_order_type,
|
|
686
|
+
"marginMode": weex_margin_mode,
|
|
687
|
+
"client_oid": client_order_id or str(int(time.time() * 1000)),
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
# Add quantity
|
|
691
|
+
if quantity is not None:
|
|
692
|
+
filters = self.get_symbol_filters(symbol)
|
|
693
|
+
data["size"] = self._format_decimal(
|
|
694
|
+
quantity,
|
|
695
|
+
step=filters.get("step_size"),
|
|
696
|
+
precision=filters.get("quantity_precision"),
|
|
697
|
+
rounding=ROUND_DOWN,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
# Add price for limit orders
|
|
701
|
+
if price is not None and match_price == "0":
|
|
702
|
+
filters = self.get_symbol_filters(symbol)
|
|
703
|
+
data["price"] = self._format_decimal(
|
|
704
|
+
price, step=filters.get("tick_size"), precision=filters.get("price_precision"), rounding=ROUND_HALF_UP
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
# Add stop loss/take profit if provided in kwargs
|
|
708
|
+
if kwargs.get("presetStopLossPrice") or kwargs.get("presetTakeProfitPrice"):
|
|
709
|
+
# Ensure we have filters for price formatting
|
|
710
|
+
if "filters" not in dir() or filters is None:
|
|
711
|
+
filters = self.get_symbol_filters(symbol)
|
|
712
|
+
if kwargs.get("presetStopLossPrice"):
|
|
713
|
+
data["presetStopLossPrice"] = self._format_decimal(
|
|
714
|
+
kwargs["presetStopLossPrice"],
|
|
715
|
+
step=filters.get("tick_size"),
|
|
716
|
+
precision=filters.get("price_precision"),
|
|
717
|
+
rounding=ROUND_HALF_UP,
|
|
718
|
+
)
|
|
719
|
+
if kwargs.get("presetTakeProfitPrice"):
|
|
720
|
+
data["presetTakeProfitPrice"] = self._format_decimal(
|
|
721
|
+
kwargs["presetTakeProfitPrice"],
|
|
722
|
+
step=filters.get("tick_size"),
|
|
723
|
+
precision=filters.get("price_precision"),
|
|
724
|
+
rounding=ROUND_HALF_UP,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
result = self._request("POST", "/capi/v2/order/placeOrder", signed=True, data=data)
|
|
728
|
+
|
|
729
|
+
# Convert response to Aster-compatible format
|
|
730
|
+
return {
|
|
731
|
+
"orderId": result.get("order_id"),
|
|
732
|
+
"symbol": symbol,
|
|
733
|
+
"status": "NEW",
|
|
734
|
+
"clientOrderId": result.get("client_oid"),
|
|
735
|
+
"price": price,
|
|
736
|
+
"origQty": quantity,
|
|
737
|
+
"executedQty": 0,
|
|
738
|
+
"type": order_type,
|
|
739
|
+
"side": side,
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
def cancel_order(self, symbol: str, order_id: int | None = None, client_order_id: str | None = None) -> dict: # type: ignore[type-arg]
|
|
743
|
+
"""Cancel a specific order."""
|
|
744
|
+
data: dict = {} # type: ignore[type-arg]
|
|
745
|
+
|
|
746
|
+
if order_id:
|
|
747
|
+
data["orderId"] = str(order_id)
|
|
748
|
+
elif client_order_id:
|
|
749
|
+
data["clientOid"] = client_order_id
|
|
750
|
+
else:
|
|
751
|
+
raise ValueError("Must provide either order_id or client_order_id")
|
|
752
|
+
|
|
753
|
+
result = self._request("POST", "/capi/v2/order/cancel_order", signed=True, data=data)
|
|
754
|
+
|
|
755
|
+
return {
|
|
756
|
+
"orderId": result.get("order_id"),
|
|
757
|
+
"symbol": symbol,
|
|
758
|
+
"status": "CANCELED" if result.get("result") else "FAILED",
|
|
759
|
+
"clientOrderId": result.get("client_oid"),
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
def get_order(self, symbol: str, order_id: int | None = None, client_order_id: str | None = None) -> dict: # type: ignore[type-arg]
|
|
763
|
+
"""Query an order."""
|
|
764
|
+
if not order_id:
|
|
765
|
+
raise ValueError("order_id is required for WEEX")
|
|
766
|
+
|
|
767
|
+
params = {"orderId": str(order_id)}
|
|
768
|
+
|
|
769
|
+
data = self._request("GET", "/capi/v2/order/detail", signed=True, params=params)
|
|
770
|
+
|
|
771
|
+
# Convert WEEX status to Aster status
|
|
772
|
+
status_map = {"open": "NEW", "filled": "FILLED", "partial_filled": "PARTIALLY_FILLED", "canceled": "CANCELED"}
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
"orderId": data.get("order_id"),
|
|
776
|
+
"symbol": self._from_weex_symbol(data.get("symbol", "")),
|
|
777
|
+
"status": status_map.get(data.get("status", ""), data.get("status")),
|
|
778
|
+
"clientOrderId": data.get("client_oid"),
|
|
779
|
+
"price": data.get("price"),
|
|
780
|
+
"origQty": data.get("size"),
|
|
781
|
+
"executedQty": data.get("filled_qty"),
|
|
782
|
+
"type": "MARKET" if data.get("order_type") == "ioc" else "LIMIT",
|
|
783
|
+
"side": "BUY" if "long" in data.get("type", "") else "SELL",
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
def get_open_orders(self, symbol: str | None = None) -> list[dict]: # type: ignore[type-arg]
|
|
787
|
+
"""Fetch open orders."""
|
|
788
|
+
params: dict = {} # type: ignore[type-arg]
|
|
789
|
+
if symbol:
|
|
790
|
+
params["symbol"] = self._to_weex_symbol(symbol)
|
|
791
|
+
|
|
792
|
+
data = self._request("GET", "/capi/v2/order/current", signed=True, params=params)
|
|
793
|
+
|
|
794
|
+
orders = []
|
|
795
|
+
for order in data:
|
|
796
|
+
weex_type = order.get("type", "")
|
|
797
|
+
|
|
798
|
+
# Determine side from WEEX type
|
|
799
|
+
if "long" in weex_type:
|
|
800
|
+
side = "BUY" if "open" in weex_type else "SELL"
|
|
801
|
+
else:
|
|
802
|
+
side = "SELL" if "open" in weex_type else "BUY"
|
|
803
|
+
|
|
804
|
+
orders.append(
|
|
805
|
+
{
|
|
806
|
+
"orderId": order.get("order_id"),
|
|
807
|
+
"symbol": self._from_weex_symbol(order.get("symbol", "")),
|
|
808
|
+
"status": "NEW" if order.get("status") == "open" else order.get("status"),
|
|
809
|
+
"clientOrderId": order.get("client_oid"),
|
|
810
|
+
"price": order.get("price"),
|
|
811
|
+
"origQty": order.get("size"),
|
|
812
|
+
"executedQty": order.get("filled_qty"),
|
|
813
|
+
"type": "LIMIT", # Simplified
|
|
814
|
+
"side": side,
|
|
815
|
+
"time": order.get("createTime"),
|
|
816
|
+
}
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
return orders
|
|
820
|
+
|
|
821
|
+
def cancel_all_orders(self, symbol: str) -> dict: # type: ignore[type-arg]
|
|
822
|
+
"""
|
|
823
|
+
Cancel all open orders for the symbol.
|
|
824
|
+
|
|
825
|
+
WEEX doesn't have a direct "cancel all by symbol" endpoint,
|
|
826
|
+
so we query open orders first, then cancel individually.
|
|
827
|
+
Batch cancel may not work reliably on WEEX.
|
|
828
|
+
"""
|
|
829
|
+
# Get all open orders for this symbol
|
|
830
|
+
open_orders = self.get_open_orders(symbol)
|
|
831
|
+
|
|
832
|
+
if not open_orders:
|
|
833
|
+
return {"message": "No orders to cancel"}
|
|
834
|
+
|
|
835
|
+
# Cancel orders individually (more reliable than batch)
|
|
836
|
+
successful = []
|
|
837
|
+
failed = []
|
|
838
|
+
|
|
839
|
+
for order in open_orders:
|
|
840
|
+
order_id = order.get("orderId")
|
|
841
|
+
if not order_id:
|
|
842
|
+
continue
|
|
843
|
+
|
|
844
|
+
try:
|
|
845
|
+
result = self.cancel_order(symbol, order_id=int(order_id))
|
|
846
|
+
successful.append(order_id)
|
|
847
|
+
except Exception as e:
|
|
848
|
+
logger.warning(f"Failed to cancel order {order_id}: {e}")
|
|
849
|
+
failed.append({"orderId": order_id, "error": str(e)})
|
|
850
|
+
|
|
851
|
+
return {
|
|
852
|
+
"success": True,
|
|
853
|
+
"cancelled": successful,
|
|
854
|
+
"failed": failed,
|
|
855
|
+
"message": f"Cancelled {len(successful)} orders, {len(failed)} failed",
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
def get_plan_orders(self, symbol: str | None = None) -> list[dict]: # type: ignore[type-arg]
|
|
859
|
+
"""
|
|
860
|
+
Fetch current plan orders (trigger orders like SL/TP).
|
|
861
|
+
|
|
862
|
+
Args:
|
|
863
|
+
symbol: Trading pair (optional, if None returns all).
|
|
864
|
+
|
|
865
|
+
Returns:
|
|
866
|
+
List of plan orders.
|
|
867
|
+
"""
|
|
868
|
+
params: dict = {} # type: ignore[type-arg]
|
|
869
|
+
if symbol:
|
|
870
|
+
params["symbol"] = self._to_weex_symbol(symbol)
|
|
871
|
+
|
|
872
|
+
try:
|
|
873
|
+
data = self._request("GET", "/capi/v2/order/currentPlan", signed=True, params=params)
|
|
874
|
+
except Exception as e:
|
|
875
|
+
logger.warning(f"Failed to get plan orders: {e}")
|
|
876
|
+
return []
|
|
877
|
+
|
|
878
|
+
if not data:
|
|
879
|
+
return []
|
|
880
|
+
|
|
881
|
+
orders = []
|
|
882
|
+
for order in data:
|
|
883
|
+
orders.append({
|
|
884
|
+
"orderId": order.get("order_id"),
|
|
885
|
+
"symbol": self._from_weex_symbol(order.get("symbol", "")),
|
|
886
|
+
"status": order.get("status"),
|
|
887
|
+
"triggerPrice": order.get("trigger_price"),
|
|
888
|
+
"executePrice": order.get("execute_price"),
|
|
889
|
+
"size": order.get("size"),
|
|
890
|
+
"type": order.get("type"),
|
|
891
|
+
"clientOrderId": order.get("client_oid"),
|
|
892
|
+
"createTime": order.get("createTime"),
|
|
893
|
+
})
|
|
894
|
+
|
|
895
|
+
return orders
|
|
896
|
+
|
|
897
|
+
def cancel_plan_order(self, symbol: str, order_id: str | int) -> dict: # type: ignore[type-arg]
|
|
898
|
+
"""
|
|
899
|
+
Cancel a specific plan order (trigger order).
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
symbol: Trading pair.
|
|
903
|
+
order_id: Plan order ID.
|
|
904
|
+
|
|
905
|
+
Returns:
|
|
906
|
+
Cancellation result.
|
|
907
|
+
"""
|
|
908
|
+
data = {"orderId": str(order_id)}
|
|
909
|
+
|
|
910
|
+
result = self._request("POST", "/capi/v2/order/cancel_plan", signed=True, data=data)
|
|
911
|
+
|
|
912
|
+
return {
|
|
913
|
+
"orderId": str(order_id),
|
|
914
|
+
"symbol": symbol,
|
|
915
|
+
"status": "CANCELED" if result.get("result") else "FAILED",
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
def cancel_all_plan_orders(self, symbol: str) -> dict: # type: ignore[type-arg]
|
|
919
|
+
"""
|
|
920
|
+
Cancel all plan orders (trigger orders like SL/TP) for the symbol.
|
|
921
|
+
|
|
922
|
+
WEEX stores SL/TP as trigger orders in /capi/v2/order/plan_order.
|
|
923
|
+
These must be cancelled separately from normal orders before
|
|
924
|
+
adjusting leverage.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
symbol: Trading pair.
|
|
928
|
+
|
|
929
|
+
Returns:
|
|
930
|
+
Cancellation result with success/failed counts.
|
|
931
|
+
"""
|
|
932
|
+
# Get all plan orders for this symbol
|
|
933
|
+
plan_orders = self.get_plan_orders(symbol)
|
|
934
|
+
|
|
935
|
+
if not plan_orders:
|
|
936
|
+
logger.debug(f"No plan orders to cancel for {symbol}")
|
|
937
|
+
return {"message": "No plan orders to cancel", "cancelled": [], "failed": []}
|
|
938
|
+
|
|
939
|
+
logger.info(f"Found {len(plan_orders)} plan orders to cancel for {symbol}")
|
|
940
|
+
|
|
941
|
+
# Cancel orders individually
|
|
942
|
+
successful = []
|
|
943
|
+
failed = []
|
|
944
|
+
|
|
945
|
+
for order in plan_orders:
|
|
946
|
+
order_id = order.get("orderId")
|
|
947
|
+
if not order_id:
|
|
948
|
+
continue
|
|
949
|
+
|
|
950
|
+
try:
|
|
951
|
+
result = self.cancel_plan_order(symbol, order_id)
|
|
952
|
+
successful.append(order_id)
|
|
953
|
+
logger.debug(f"Cancelled plan order {order_id}")
|
|
954
|
+
except Exception as e:
|
|
955
|
+
logger.warning(f"Failed to cancel plan order {order_id}: {e}")
|
|
956
|
+
failed.append({"orderId": order_id, "error": str(e)})
|
|
957
|
+
|
|
958
|
+
logger.info(f"Cancelled {len(successful)} plan orders, {len(failed)} failed for {symbol}")
|
|
959
|
+
|
|
960
|
+
return {
|
|
961
|
+
"success": True,
|
|
962
|
+
"cancelled": successful,
|
|
963
|
+
"failed": failed,
|
|
964
|
+
"message": f"Cancelled {len(successful)} plan orders, {len(failed)} failed",
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
# ==================== Advanced Trading ====================
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def place_sl_tp_orders(
|
|
971
|
+
self,
|
|
972
|
+
symbol: str,
|
|
973
|
+
side: str,
|
|
974
|
+
quantity: float,
|
|
975
|
+
stop_loss_price: float | None = None,
|
|
976
|
+
take_profit_price: float | None = None,
|
|
977
|
+
trigger_type: str = "MARK_PRICE",
|
|
978
|
+
) -> dict: # type: ignore[type-arg]
|
|
979
|
+
"""
|
|
980
|
+
Submit stop-loss and take-profit orders using WEEX's dedicated TP/SL API.
|
|
981
|
+
|
|
982
|
+
Uses /capi/v2/order/placeTpSlOrder which correctly handles trigger direction:
|
|
983
|
+
- loss_plan: triggers when price FALLS to trigger_price (for stop loss)
|
|
984
|
+
- profit_plan: triggers when price RISES to trigger_price (for take profit)
|
|
985
|
+
|
|
986
|
+
Args:
|
|
987
|
+
symbol: Trading pair (e.g., "BTCUSDT")
|
|
988
|
+
side: "SELL" for closing long positions, "BUY" for closing short positions
|
|
989
|
+
quantity: Order quantity in base coin
|
|
990
|
+
stop_loss_price: Stop loss trigger price (optional)
|
|
991
|
+
take_profit_price: Take profit trigger price (optional)
|
|
992
|
+
trigger_type: Not used for WEEX (kept for API compatibility)
|
|
993
|
+
|
|
994
|
+
Note: marginMode is dynamically inferred from current position.
|
|
995
|
+
"""
|
|
996
|
+
result: dict = {"stop_loss": None, "take_profit": None} # type: ignore[type-arg]
|
|
997
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
998
|
+
|
|
999
|
+
# Get symbol filters for price precision formatting
|
|
1000
|
+
filters = self.get_symbol_filters(symbol)
|
|
1001
|
+
tick_size = filters.get("tick_size")
|
|
1002
|
+
price_precision = filters.get("price_precision")
|
|
1003
|
+
step_size = filters.get("step_size")
|
|
1004
|
+
quantity_precision = filters.get("quantity_precision")
|
|
1005
|
+
|
|
1006
|
+
# Dynamically determine marginMode from current position
|
|
1007
|
+
# WEEX API requires marginMode to match account's current setting
|
|
1008
|
+
# 1 = Cross (全仓), 3 = Isolated (逐仓)
|
|
1009
|
+
weex_margin_mode = 1 # Default to Cross (integer for placeTpSlOrder)
|
|
1010
|
+
try:
|
|
1011
|
+
positions = self.get_positions(symbol)
|
|
1012
|
+
if positions:
|
|
1013
|
+
margin_type = positions[0].get("margin_type", "cross")
|
|
1014
|
+
weex_margin_mode = 3 if margin_type == "isolated" else 1
|
|
1015
|
+
logger.debug(f"Inferred marginMode from position: {margin_type} -> {weex_margin_mode}")
|
|
1016
|
+
except Exception as e:
|
|
1017
|
+
logger.warning(f"Could not get position for marginMode inference, using default Cross: {e}")
|
|
1018
|
+
|
|
1019
|
+
# Determine position side based on closing direction
|
|
1020
|
+
# If side is SELL (closing long), position is "long"
|
|
1021
|
+
# If side is BUY (closing short), position is "short"
|
|
1022
|
+
position_side = "long" if side.upper() == "SELL" else "short"
|
|
1023
|
+
|
|
1024
|
+
if stop_loss_price:
|
|
1025
|
+
# Use placeTpSlOrder API with planType="loss_plan" for stop loss
|
|
1026
|
+
# This ensures the order triggers when price FALLS to trigger_price
|
|
1027
|
+
sl_data = {
|
|
1028
|
+
"symbol": weex_symbol,
|
|
1029
|
+
"clientOrderId": f"sl-{uuid4().hex}",
|
|
1030
|
+
"planType": "loss_plan", # Key: identifies this as stop loss
|
|
1031
|
+
"triggerPrice": self._format_decimal(
|
|
1032
|
+
stop_loss_price, step=tick_size, precision=price_precision, rounding=ROUND_HALF_UP
|
|
1033
|
+
),
|
|
1034
|
+
"executePrice": "0", # Market price execution
|
|
1035
|
+
"size": self._format_decimal(
|
|
1036
|
+
quantity, step=step_size, precision=quantity_precision, rounding=ROUND_DOWN
|
|
1037
|
+
),
|
|
1038
|
+
"positionSide": position_side,
|
|
1039
|
+
"marginMode": weex_margin_mode,
|
|
1040
|
+
}
|
|
1041
|
+
try:
|
|
1042
|
+
result["stop_loss"] = self._request("POST", "/capi/v2/order/placeTpSlOrder", signed=True, data=sl_data)
|
|
1043
|
+
logger.info(f"Placed SL order: trigger={stop_loss_price}, side={position_side}")
|
|
1044
|
+
except Exception as e:
|
|
1045
|
+
logger.error(f"Failed to place SL order: {e}")
|
|
1046
|
+
|
|
1047
|
+
if take_profit_price:
|
|
1048
|
+
# Use placeTpSlOrder API with planType="profit_plan" for take profit
|
|
1049
|
+
# This ensures the order triggers when price RISES to trigger_price
|
|
1050
|
+
tp_data = {
|
|
1051
|
+
"symbol": weex_symbol,
|
|
1052
|
+
"clientOrderId": f"tp-{uuid4().hex}",
|
|
1053
|
+
"planType": "profit_plan", # Key: identifies this as take profit
|
|
1054
|
+
"triggerPrice": self._format_decimal(
|
|
1055
|
+
take_profit_price, step=tick_size, precision=price_precision, rounding=ROUND_HALF_UP
|
|
1056
|
+
),
|
|
1057
|
+
"executePrice": "0", # Market price execution
|
|
1058
|
+
"size": self._format_decimal(
|
|
1059
|
+
quantity, step=step_size, precision=quantity_precision, rounding=ROUND_DOWN
|
|
1060
|
+
),
|
|
1061
|
+
"positionSide": position_side,
|
|
1062
|
+
"marginMode": weex_margin_mode,
|
|
1063
|
+
}
|
|
1064
|
+
try:
|
|
1065
|
+
result["take_profit"] = self._request("POST", "/capi/v2/order/placeTpSlOrder", signed=True, data=tp_data)
|
|
1066
|
+
logger.info(f"Placed TP order: trigger={take_profit_price}, side={position_side}")
|
|
1067
|
+
except Exception as e:
|
|
1068
|
+
logger.error(f"Failed to place TP order: {e}")
|
|
1069
|
+
|
|
1070
|
+
return result
|
|
1071
|
+
|
|
1072
|
+
def close_position(self, symbol: str, percent: float = 100.0) -> dict: # type: ignore[type-arg]
|
|
1073
|
+
"""Close an existing position by percentage."""
|
|
1074
|
+
positions = self.get_positions(symbol)
|
|
1075
|
+
|
|
1076
|
+
if not positions:
|
|
1077
|
+
return {"message": "No position to close"}
|
|
1078
|
+
|
|
1079
|
+
position = positions[0]
|
|
1080
|
+
position_amt = position["position_amt"]
|
|
1081
|
+
|
|
1082
|
+
if position_amt == 0:
|
|
1083
|
+
return {"message": "No position to close"}
|
|
1084
|
+
|
|
1085
|
+
# Calculate close quantity
|
|
1086
|
+
close_qty = abs(position_amt) * (percent / 100.0)
|
|
1087
|
+
|
|
1088
|
+
# Determine close direction
|
|
1089
|
+
# If position_amt > 0 (LONG), close with type 3 (close long)
|
|
1090
|
+
# If position_amt < 0 (SHORT), close with type 4 (close short)
|
|
1091
|
+
side = "SELL" if position_amt > 0 else "BUY"
|
|
1092
|
+
|
|
1093
|
+
return self.place_order(symbol=symbol, side=side, order_type="MARKET", quantity=close_qty, reduce_only=True)
|
|
1094
|
+
|
|
1095
|
+
# ==================== Helpers ====================
|
|
1096
|
+
|
|
1097
|
+
def validate_order_params(self, symbol: str, price: float, quantity: float) -> dict: # type: ignore[type-arg]
|
|
1098
|
+
"""Validate order parameters against exchange filters."""
|
|
1099
|
+
filters = self.get_symbol_filters(symbol)
|
|
1100
|
+
|
|
1101
|
+
tick_size = filters.get("tick_size", 0.1)
|
|
1102
|
+
adjusted_price = round(price / tick_size) * tick_size
|
|
1103
|
+
|
|
1104
|
+
step_size = filters.get("step_size", 0.00001)
|
|
1105
|
+
adjusted_quantity = round(quantity / step_size) * step_size
|
|
1106
|
+
|
|
1107
|
+
notional = adjusted_price * adjusted_quantity
|
|
1108
|
+
min_notional = filters.get("min_notional", 1)
|
|
1109
|
+
|
|
1110
|
+
validation = {
|
|
1111
|
+
"valid": True,
|
|
1112
|
+
"adjusted_price": adjusted_price,
|
|
1113
|
+
"adjusted_quantity": adjusted_quantity,
|
|
1114
|
+
"notional": notional,
|
|
1115
|
+
"errors": [],
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
if adjusted_quantity < filters.get("min_qty", 0):
|
|
1119
|
+
validation["valid"] = False
|
|
1120
|
+
validation["errors"].append(f"Quantity {adjusted_quantity} below minimum {filters['min_qty']}")
|
|
1121
|
+
|
|
1122
|
+
if notional < min_notional:
|
|
1123
|
+
validation["valid"] = False
|
|
1124
|
+
validation["errors"].append(f"Notional {notional} below minimum {min_notional}")
|
|
1125
|
+
|
|
1126
|
+
return validation
|
|
1127
|
+
|
|
1128
|
+
def calculate_liquidation_price(
|
|
1129
|
+
self, entry_price: float, leverage: int, side: str, maintenance_margin_rate: float = 0.005
|
|
1130
|
+
) -> float:
|
|
1131
|
+
"""Calculate approximate liquidation price."""
|
|
1132
|
+
if side.upper() in ["LONG", "BUY"]:
|
|
1133
|
+
liq_price = entry_price * (1 - (1 / leverage) + maintenance_margin_rate)
|
|
1134
|
+
else:
|
|
1135
|
+
liq_price = entry_price * (1 + (1 / leverage) - maintenance_margin_rate)
|
|
1136
|
+
|
|
1137
|
+
return liq_price
|
|
1138
|
+
|
|
1139
|
+
# ==================== Order History ====================
|
|
1140
|
+
|
|
1141
|
+
def get_order_history(
|
|
1142
|
+
self,
|
|
1143
|
+
symbol: str,
|
|
1144
|
+
page_size: int = 100,
|
|
1145
|
+
create_date: int | None = None,
|
|
1146
|
+
end_create_date: int | None = None,
|
|
1147
|
+
) -> list[dict]: # type: ignore[type-arg]
|
|
1148
|
+
"""
|
|
1149
|
+
Fetch historical order records.
|
|
1150
|
+
|
|
1151
|
+
WEEX API: GET /capi/v2/order/history
|
|
1152
|
+
|
|
1153
|
+
Args:
|
|
1154
|
+
symbol: Trading pair, e.g., "BTCUSDT" (will be converted to cmt_btcusdt)
|
|
1155
|
+
page_size: Records per page, default 100, max 100
|
|
1156
|
+
create_date: Start timestamp (milliseconds)
|
|
1157
|
+
end_create_date: End timestamp (milliseconds)
|
|
1158
|
+
|
|
1159
|
+
Returns:
|
|
1160
|
+
List of orders
|
|
1161
|
+
"""
|
|
1162
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
1163
|
+
|
|
1164
|
+
params: dict = {"symbol": weex_symbol, "pageSize": min(page_size, 100)} # type: ignore[type-arg]
|
|
1165
|
+
if create_date:
|
|
1166
|
+
params["createDate"] = create_date
|
|
1167
|
+
if end_create_date:
|
|
1168
|
+
params["endCreateDate"] = end_create_date
|
|
1169
|
+
|
|
1170
|
+
data = self._request("GET", "/capi/v2/order/history", signed=True, params=params)
|
|
1171
|
+
|
|
1172
|
+
# Convert to standardized format
|
|
1173
|
+
orders = []
|
|
1174
|
+
for order in data:
|
|
1175
|
+
orders.append({
|
|
1176
|
+
"order_id": order.get("order_id"),
|
|
1177
|
+
"client_oid": order.get("client_oid"),
|
|
1178
|
+
"symbol": self._from_weex_symbol(order.get("symbol", "")),
|
|
1179
|
+
"symbol_raw": order.get("symbol"),
|
|
1180
|
+
"size": order.get("size"),
|
|
1181
|
+
"filled_qty": order.get("filled_qty"),
|
|
1182
|
+
"price": order.get("price"),
|
|
1183
|
+
"price_avg": order.get("price_avg"),
|
|
1184
|
+
"fee": order.get("fee"),
|
|
1185
|
+
"status": order.get("status"),
|
|
1186
|
+
"type": order.get("type"),
|
|
1187
|
+
"order_type": order.get("order_type"),
|
|
1188
|
+
"total_profits": order.get("totalProfits"),
|
|
1189
|
+
"contracts": order.get("contracts"),
|
|
1190
|
+
"filled_qty_contracts": order.get("filledQtyContracts"),
|
|
1191
|
+
"create_time": order.get("createTime"),
|
|
1192
|
+
"preset_take_profit_price": order.get("presetTakeProfitPrice"),
|
|
1193
|
+
"preset_stop_loss_price": order.get("presetStopLossPrice"),
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
return orders
|
|
1197
|
+
|
|
1198
|
+
def get_order_fills(
|
|
1199
|
+
self,
|
|
1200
|
+
symbol: str,
|
|
1201
|
+
page_size: int = 100,
|
|
1202
|
+
create_date: int | None = None,
|
|
1203
|
+
end_create_date: int | None = None,
|
|
1204
|
+
) -> list[dict]: # type: ignore[type-arg]
|
|
1205
|
+
"""
|
|
1206
|
+
Fetch trade fill records.
|
|
1207
|
+
|
|
1208
|
+
WEEX API: GET /capi/v2/order/fills
|
|
1209
|
+
|
|
1210
|
+
Args:
|
|
1211
|
+
symbol: Trading pair, e.g., "BTCUSDT" (will be converted to cmt_btcusdt)
|
|
1212
|
+
page_size: Records per page, default 100, max 100
|
|
1213
|
+
create_date: Start timestamp (milliseconds)
|
|
1214
|
+
end_create_date: End timestamp (milliseconds)
|
|
1215
|
+
|
|
1216
|
+
Returns:
|
|
1217
|
+
List of fills (includes tradeId, fillPrice, fillQty, fillValue, fillFee, realizePnl)
|
|
1218
|
+
"""
|
|
1219
|
+
weex_symbol = self._to_weex_symbol(symbol)
|
|
1220
|
+
|
|
1221
|
+
params: dict = {"symbol": weex_symbol, "pageSize": min(page_size, 100)} # type: ignore[type-arg]
|
|
1222
|
+
if create_date:
|
|
1223
|
+
params["createDate"] = create_date
|
|
1224
|
+
if end_create_date:
|
|
1225
|
+
params["endCreateDate"] = end_create_date
|
|
1226
|
+
|
|
1227
|
+
data = self._request("GET", "/capi/v2/order/fills", signed=True, params=params)
|
|
1228
|
+
|
|
1229
|
+
# Convert to standardized format
|
|
1230
|
+
fills = []
|
|
1231
|
+
for fill in data:
|
|
1232
|
+
fills.append({
|
|
1233
|
+
"trade_id": fill.get("tradeId"),
|
|
1234
|
+
"order_id": fill.get("order_id"),
|
|
1235
|
+
"symbol": self._from_weex_symbol(fill.get("symbol", "")),
|
|
1236
|
+
"symbol_raw": fill.get("symbol"),
|
|
1237
|
+
"type": fill.get("type"), # open_long/close_long etc.
|
|
1238
|
+
"fill_price": fill.get("fillPrice"),
|
|
1239
|
+
"fill_qty": fill.get("fillQty"),
|
|
1240
|
+
"fill_value": fill.get("fillValue"),
|
|
1241
|
+
"fill_fee": fill.get("fillFee"),
|
|
1242
|
+
"realize_pnl": fill.get("realizePnl"),
|
|
1243
|
+
"created_time": fill.get("createdTime"),
|
|
1244
|
+
})
|
|
1245
|
+
|
|
1246
|
+
return fills
|