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/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