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.
@@ -0,0 +1,601 @@
1
+ """
2
+ Aster Futures API Client
3
+
4
+ Implementation for Aster DEX futures trading.
5
+ Uses /fapi/v1/ and /fapi/v2/ endpoints.
6
+ """
7
+
8
+ import hashlib
9
+ import hmac
10
+ from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal
11
+
12
+ from loguru import logger
13
+
14
+ from .base import BaseFuturesClient
15
+ from .config import ExchangeConfig
16
+
17
+
18
+ class AsterFuturesClient(BaseFuturesClient):
19
+ """Aster Futures REST API Client"""
20
+
21
+ DEFAULT_BASE_URL = "https://fapi.asterdex.com"
22
+
23
+ def __init__(
24
+ self,
25
+ api_key: str,
26
+ api_secret: str,
27
+ base_url: str | None = None,
28
+ max_retries: int = 5,
29
+ retry_delay: float = 1.0,
30
+ timeout: float = 5.0,
31
+ proxy_url: str | None = None
32
+ ):
33
+ super().__init__(
34
+ api_key=api_key,
35
+ api_secret=api_secret,
36
+ base_url=base_url or self.DEFAULT_BASE_URL,
37
+ max_retries=max_retries,
38
+ retry_delay=retry_delay,
39
+ timeout=timeout,
40
+ proxy_url=proxy_url
41
+ )
42
+
43
+ def _setup_session_headers(self) -> None:
44
+ """Setup Aster-specific headers."""
45
+ self.session.headers.update({
46
+ "Content-Type": "application/json",
47
+ "X-MBX-APIKEY": self.api_key
48
+ })
49
+
50
+ @classmethod
51
+ def from_config(cls, config: ExchangeConfig) -> "AsterFuturesClient":
52
+ """Create client from ExchangeConfig."""
53
+ return cls(
54
+ api_key=config.api_key,
55
+ api_secret=config.api_secret,
56
+ base_url=config.base_url or cls.DEFAULT_BASE_URL,
57
+ proxy_url=config.proxy_url
58
+ )
59
+
60
+ def _generate_signature(self, params: dict) -> str: # type: ignore[type-arg]
61
+ """
62
+ Generate request signature.
63
+
64
+ Note: Aster DEX does NOT require sorted parameters.
65
+ Uses insertion order (tested and confirmed).
66
+ """
67
+ query_string = "&".join([f"{k}={v}" for k, v in params.items()])
68
+ signature = hmac.new(
69
+ self.api_secret.encode('utf-8'),
70
+ query_string.encode('utf-8'),
71
+ hashlib.sha256
72
+ ).hexdigest()
73
+ return signature
74
+
75
+ # ==================== Market Data ====================
76
+
77
+ def get_klines(self, symbol: str, interval: str = "1h", limit: int = 200) -> list[dict]: # type: ignore[type-arg]
78
+ """Fetch candlestick (kline) data."""
79
+ params = {
80
+ "symbol": symbol,
81
+ "interval": interval,
82
+ "limit": limit
83
+ }
84
+
85
+ data = self._request("GET", "/fapi/v1/klines", params=params)
86
+
87
+ klines = []
88
+ for k in data:
89
+ klines.append({
90
+ "open_time": k[0],
91
+ "open": float(k[1]),
92
+ "high": float(k[2]),
93
+ "low": float(k[3]),
94
+ "close": float(k[4]),
95
+ "volume": float(k[5]),
96
+ "close_time": k[6],
97
+ "quote_volume": float(k[7]),
98
+ "trades": int(k[8]),
99
+ })
100
+
101
+ return klines
102
+
103
+ def get_mark_price(self, symbol: str) -> dict: # type: ignore[type-arg]
104
+ """Fetch mark price information."""
105
+ params = {"symbol": symbol}
106
+ data = self._request("GET", "/fapi/v1/premiumIndex", params=params)
107
+
108
+ return {
109
+ "symbol": data["symbol"],
110
+ "mark_price": float(data["markPrice"]),
111
+ "index_price": float(data["indexPrice"]),
112
+ "funding_rate": float(data["lastFundingRate"]),
113
+ "next_funding_time": data["nextFundingTime"],
114
+ }
115
+
116
+ def get_funding_rate_history(self, symbol: str, limit: int = 100) -> list[dict]: # type: ignore[type-arg]
117
+ """Fetch historical funding rates."""
118
+ params = {
119
+ "symbol": symbol,
120
+ "limit": limit
121
+ }
122
+ return self._request("GET", "/fapi/v1/fundingRate", params=params)
123
+
124
+ def get_open_interest(self, symbol: str) -> dict: # type: ignore[type-arg]
125
+ """Fetch open interest statistics."""
126
+ params = {"symbol": symbol}
127
+ data = self._request("GET", "/fapi/v1/openInterest", params=params)
128
+
129
+ return {
130
+ "symbol": data["symbol"],
131
+ "open_interest": float(data["openInterest"]),
132
+ "timestamp": data["time"]
133
+ }
134
+
135
+ def get_ticker_24hr(self, symbol: str) -> dict: # type: ignore[type-arg]
136
+ """Fetch 24-hour price change statistics."""
137
+ params = {"symbol": symbol}
138
+ return self._request("GET", "/fapi/v1/ticker/24hr", params=params)
139
+
140
+ def get_depth(self, symbol: str, limit: int = 20) -> dict: # type: ignore[type-arg]
141
+ """Fetch orderbook depth."""
142
+ params = {"symbol": symbol, "limit": limit}
143
+ return self._request("GET", "/fapi/v1/depth", params=params)
144
+
145
+ # ==================== Exchange Metadata ====================
146
+
147
+ def get_exchange_info(self) -> dict: # type: ignore[type-arg]
148
+ """Fetch exchange information."""
149
+ return self._request("GET", "/fapi/v1/exchangeInfo")
150
+
151
+ def get_symbol_filters(self, symbol: str, force_refresh: bool = False) -> dict: # type: ignore[type-arg]
152
+ """Fetch symbol filters (precision, min notional, etc.)."""
153
+ if symbol in self._symbol_filters and not force_refresh:
154
+ return self._symbol_filters[symbol]
155
+
156
+ exchange_info = self.get_exchange_info()
157
+
158
+ for s in exchange_info['symbols']:
159
+ if s['symbol'] == symbol:
160
+ filters: dict = {} # type: ignore[type-arg]
161
+
162
+ # Contract specifications
163
+ filters['contract_type'] = s.get('contractType', '')
164
+ filters['contract_size'] = float(s.get('contractSize', 1.0))
165
+ filters['contract_status'] = s.get('contractStatus', '')
166
+ filters['underlying_type'] = s.get('underlyingType', '')
167
+
168
+ # Precision settings
169
+ filters['price_precision'] = int(s.get('pricePrecision', 0))
170
+ filters['quantity_precision'] = int(s.get('quantityPrecision', 0))
171
+ filters['base_asset_precision'] = int(s.get('baseAssetPrecision', 0))
172
+ filters['quote_precision'] = int(s.get('quotePrecision', 0))
173
+
174
+ # Extract filter rules
175
+ for f in s['filters']:
176
+ filter_type = f['filterType']
177
+
178
+ if filter_type == 'PRICE_FILTER':
179
+ filters['tick_size'] = float(f['tickSize'])
180
+ filters['min_price'] = float(f['minPrice'])
181
+ filters['max_price'] = float(f['maxPrice'])
182
+ elif filter_type == 'LOT_SIZE':
183
+ filters['step_size'] = float(f['stepSize'])
184
+ filters['min_qty'] = float(f['minQty'])
185
+ filters['max_qty'] = float(f['maxQty'])
186
+ elif filter_type == 'NOTIONAL':
187
+ min_notional_val = (
188
+ f.get('minNotional') or
189
+ f.get('minNotionalValue') or
190
+ f.get('notional') or
191
+ f.get('notionalValue')
192
+ )
193
+ if min_notional_val:
194
+ filters['min_notional'] = float(min_notional_val)
195
+ else:
196
+ logger.warning(f"NOTIONAL filter found for {symbol} but no minNotional field")
197
+
198
+ max_notional_val = f.get('maxNotional') or f.get('maxNotionalValue')
199
+ if max_notional_val:
200
+ filters['max_notional'] = float(max_notional_val)
201
+ elif filter_type == 'MIN_NOTIONAL':
202
+ if 'min_notional' not in filters:
203
+ filters['min_notional'] = float(f.get('notional', f.get('notionalValue', 0)))
204
+ elif filter_type == 'MAX_NUM_ORDERS':
205
+ filters['max_num_orders'] = int(f.get('maxNumOrders', 0))
206
+ elif filter_type == 'MAX_NUM_ALGO_ORDERS':
207
+ filters['max_num_algo_orders'] = int(f.get('maxNumAlgoOrders', 0))
208
+ elif filter_type == 'PERCENT_PRICE':
209
+ filters['multiplier_up'] = float(f.get('multiplierUp', 0))
210
+ filters['multiplier_down'] = float(f.get('multiplierDown', 0))
211
+ filters['multiplier_decimal'] = float(f.get('multiplierDecimal', 0))
212
+
213
+ self._symbol_filters[symbol] = filters
214
+ return filters
215
+
216
+ raise ValueError(f"Symbol {symbol} not found")
217
+
218
+ def get_leverage_bracket(self, symbol: str | None = None, force_refresh: bool = False) -> dict: # type: ignore[type-arg]
219
+ """Fetch leverage bracket information."""
220
+ cache_key = symbol or 'ALL'
221
+
222
+ if cache_key in self._leverage_brackets and not force_refresh:
223
+ return self._leverage_brackets[cache_key]
224
+
225
+ params: dict = {} # type: ignore[type-arg]
226
+ if symbol:
227
+ params['symbol'] = symbol
228
+
229
+ try:
230
+ data = self._request("GET", "/fapi/v1/leverageBracket", params=params)
231
+ self._leverage_brackets[cache_key] = data
232
+ return data
233
+ except Exception as e:
234
+ logger.debug(f"Leverage bracket endpoint not available: {e}")
235
+ return {}
236
+
237
+ # ==================== Account ====================
238
+
239
+ def get_account(self) -> dict: # type: ignore[type-arg]
240
+ """Fetch account information."""
241
+ data = self._request("GET", "/fapi/v2/account", signed=True)
242
+
243
+ return {
244
+ "total_wallet_balance": float(data["totalWalletBalance"]),
245
+ "total_unrealized_profit": float(data["totalUnrealizedProfit"]),
246
+ "total_margin_balance": float(data["totalMarginBalance"]),
247
+ "total_position_initial_margin": float(data["totalPositionInitialMargin"]),
248
+ "total_open_order_initial_margin": float(data["totalOpenOrderInitialMargin"]),
249
+ "available_balance": float(data["availableBalance"]),
250
+ "max_withdraw_amount": float(data["maxWithdrawAmount"]),
251
+ "assets": data.get("assets", []),
252
+ "positions": data.get("positions", [])
253
+ }
254
+
255
+ def get_positions(self, symbol: str | None = None) -> list[dict]: # type: ignore[type-arg]
256
+ """Fetch current positions."""
257
+ params: dict = {} # type: ignore[type-arg]
258
+ if symbol:
259
+ params['symbol'] = symbol
260
+
261
+ data = self._request("GET", "/fapi/v2/positionRisk", signed=True, params=params)
262
+
263
+ positions = []
264
+ for p in data:
265
+ if float(p['positionAmt']) != 0:
266
+ positions.append({
267
+ "symbol": p["symbol"],
268
+ "position_amt": float(p["positionAmt"]),
269
+ "entry_price": float(p["entryPrice"]),
270
+ "mark_price": float(p["markPrice"]),
271
+ "unrealized_profit": float(p["unRealizedProfit"]),
272
+ "liquidation_price": float(p["liquidationPrice"]),
273
+ "leverage": int(p["leverage"]),
274
+ "margin_type": p["marginType"],
275
+ "isolated_margin": float(p.get("isolatedMargin", 0)),
276
+ "position_side": p.get("positionSide", "BOTH")
277
+ })
278
+
279
+ return positions
280
+
281
+ def get_balance(self) -> dict: # type: ignore[type-arg]
282
+ """Fetch account balance summary."""
283
+ account = self.get_account()
284
+ return {
285
+ "available_balance": account["available_balance"],
286
+ "total_margin_balance": account["total_margin_balance"],
287
+ "total_unrealized_profit": account["total_unrealized_profit"]
288
+ }
289
+
290
+ # ==================== Trading ====================
291
+
292
+ def set_leverage(self, symbol: str, leverage: int) -> dict: # type: ignore[type-arg]
293
+ """Configure leverage for a symbol."""
294
+ params = {
295
+ "symbol": symbol,
296
+ "leverage": leverage
297
+ }
298
+ return self._request("POST", "/fapi/v1/leverage", signed=True, params=params)
299
+
300
+ def set_margin_type(self, symbol: str, margin_type: str = "ISOLATED") -> dict: # type: ignore[type-arg]
301
+ """Configure margin mode."""
302
+ params = {
303
+ "symbol": symbol,
304
+ "marginType": margin_type
305
+ }
306
+ return self._request("POST", "/fapi/v1/marginType", signed=True, params=params)
307
+
308
+ def place_order(
309
+ self,
310
+ symbol: str,
311
+ side: str,
312
+ order_type: str = "LIMIT",
313
+ quantity: float | None = None,
314
+ price: float | None = None,
315
+ stop_price: float | None = None,
316
+ reduce_only: bool = False,
317
+ time_in_force: str = "GTC",
318
+ client_order_id: str | None = None,
319
+ **kwargs: dict # type: ignore[type-arg]
320
+ ) -> dict: # type: ignore[type-arg]
321
+ """Place an order."""
322
+ params: dict = { # type: ignore[type-arg]
323
+ "symbol": symbol,
324
+ "side": side,
325
+ "type": order_type,
326
+ }
327
+
328
+ filters: dict | None = None # type: ignore[type-arg]
329
+
330
+ if quantity is not None:
331
+ filters = filters or self.get_symbol_filters(symbol)
332
+ params["quantity"] = self._format_decimal(
333
+ quantity,
334
+ step=filters.get("step_size"),
335
+ precision=filters.get("quantity_precision"),
336
+ rounding=ROUND_DOWN
337
+ )
338
+ if price is not None:
339
+ filters = filters or self.get_symbol_filters(symbol)
340
+ params["price"] = self._format_decimal(
341
+ price,
342
+ step=filters.get("tick_size"),
343
+ precision=filters.get("price_precision"),
344
+ rounding=ROUND_HALF_UP
345
+ )
346
+ if stop_price is not None:
347
+ filters = filters or self.get_symbol_filters(symbol)
348
+ params["stopPrice"] = self._format_decimal(
349
+ stop_price,
350
+ step=filters.get("tick_size"),
351
+ precision=filters.get("price_precision"),
352
+ rounding=ROUND_HALF_UP
353
+ )
354
+ if reduce_only:
355
+ params["reduceOnly"] = "true"
356
+ if time_in_force and order_type == "LIMIT":
357
+ params["timeInForce"] = time_in_force
358
+ if client_order_id:
359
+ params["newClientOrderId"] = client_order_id
360
+
361
+ params.update(kwargs)
362
+
363
+ return self._request("POST", "/fapi/v1/order", signed=True, params=params)
364
+
365
+ def cancel_order(
366
+ self,
367
+ symbol: str,
368
+ order_id: int | None = None,
369
+ client_order_id: str | None = None
370
+ ) -> dict: # type: ignore[type-arg]
371
+ """Cancel a specific order."""
372
+ params = {"symbol": symbol}
373
+
374
+ if order_id:
375
+ params["orderId"] = order_id
376
+ elif client_order_id:
377
+ params["origClientOrderId"] = client_order_id
378
+ else:
379
+ raise ValueError("Must provide either order_id or client_order_id")
380
+
381
+ return self._request("DELETE", "/fapi/v1/order", signed=True, params=params)
382
+
383
+ def get_order(
384
+ self,
385
+ symbol: str,
386
+ order_id: int | None = None,
387
+ client_order_id: str | None = None
388
+ ) -> dict: # type: ignore[type-arg]
389
+ """Query an order."""
390
+ params = {"symbol": symbol}
391
+
392
+ if order_id:
393
+ params["orderId"] = order_id
394
+ elif client_order_id:
395
+ params["origClientOrderId"] = client_order_id
396
+ else:
397
+ raise ValueError("Must provide either order_id or client_order_id")
398
+
399
+ return self._request("GET", "/fapi/v1/order", signed=True, params=params)
400
+
401
+ def get_open_orders(self, symbol: str | None = None) -> list[dict]: # type: ignore[type-arg]
402
+ """Fetch open orders."""
403
+ params: dict = {} # type: ignore[type-arg]
404
+ if symbol:
405
+ params["symbol"] = symbol
406
+
407
+ return self._request("GET", "/fapi/v1/openOrders", signed=True, params=params)
408
+
409
+ def cancel_all_orders(self, symbol: str) -> dict: # type: ignore[type-arg]
410
+ """Cancel all open orders for the symbol."""
411
+ params = {"symbol": symbol}
412
+ return self._request("DELETE", "/fapi/v1/allOpenOrders", signed=True, params=params)
413
+
414
+ # ==================== Advanced Trading ====================
415
+
416
+ def place_sl_tp_orders(
417
+ self,
418
+ symbol: str,
419
+ side: str,
420
+ quantity: float,
421
+ stop_loss_price: float | None = None,
422
+ take_profit_price: float | None = None,
423
+ trigger_type: str = "MARK_PRICE"
424
+ ) -> dict: # type: ignore[type-arg]
425
+ """Submit stop-loss and take-profit orders."""
426
+ filters = self.get_symbol_filters(symbol)
427
+ tick_size = filters.get("tick_size")
428
+ tick_decimal = Decimal(str(tick_size)) if tick_size else None
429
+
430
+ def _align_price(price: float | None) -> float | None:
431
+ if price is None or tick_decimal is None or tick_decimal <= 0:
432
+ return price
433
+ return float(Decimal(str(price)).quantize(tick_decimal, rounding=ROUND_HALF_UP))
434
+
435
+ stop_loss_price = _align_price(stop_loss_price)
436
+ take_profit_price = _align_price(take_profit_price)
437
+
438
+ result: dict = {"stop_loss": None, "take_profit": None} # type: ignore[type-arg]
439
+
440
+ if stop_loss_price:
441
+ sl_order = self.place_order(
442
+ symbol=symbol,
443
+ side=side,
444
+ order_type="STOP_MARKET",
445
+ quantity=quantity,
446
+ stop_price=stop_loss_price,
447
+ reduce_only=True,
448
+ workingType=trigger_type
449
+ )
450
+ result["stop_loss"] = sl_order
451
+
452
+ if take_profit_price:
453
+ tp_order = self.place_order(
454
+ symbol=symbol,
455
+ side=side,
456
+ order_type="TAKE_PROFIT_MARKET",
457
+ quantity=quantity,
458
+ stop_price=take_profit_price,
459
+ reduce_only=True,
460
+ workingType=trigger_type
461
+ )
462
+ result["take_profit"] = tp_order
463
+
464
+ return result
465
+
466
+ def close_position(self, symbol: str, percent: float = 100.0) -> dict: # type: ignore[type-arg]
467
+ """Close an existing position by percentage."""
468
+ positions = self.get_positions(symbol)
469
+
470
+ if not positions:
471
+ return {"message": "No position to close"}
472
+
473
+ position = positions[0]
474
+ position_amt = position["position_amt"]
475
+
476
+ close_qty = abs(position_amt) * (percent / 100.0)
477
+ side = "SELL" if position_amt > 0 else "BUY"
478
+
479
+ return self.place_order(
480
+ symbol=symbol,
481
+ side=side,
482
+ order_type="MARKET",
483
+ quantity=close_qty,
484
+ reduce_only=True
485
+ )
486
+
487
+ # ==================== Helpers ====================
488
+
489
+ def validate_order_params(self, symbol: str, price: float, quantity: float) -> dict: # type: ignore[type-arg]
490
+ """Validate order parameters against exchange filters."""
491
+ filters = self.get_symbol_filters(symbol)
492
+
493
+ tick_size = filters['tick_size']
494
+ adjusted_price = round(price / tick_size) * tick_size
495
+
496
+ step_size = filters['step_size']
497
+ adjusted_quantity = round(quantity / step_size) * step_size
498
+
499
+ notional = adjusted_price * adjusted_quantity
500
+ min_notional = filters.get('min_notional', 0)
501
+
502
+ validation = {
503
+ "valid": True,
504
+ "adjusted_price": adjusted_price,
505
+ "adjusted_quantity": adjusted_quantity,
506
+ "notional": notional,
507
+ "errors": []
508
+ }
509
+
510
+ if adjusted_price < filters['min_price']:
511
+ validation["valid"] = False
512
+ validation["errors"].append(f"Price {adjusted_price} below minimum {filters['min_price']}")
513
+
514
+ if adjusted_quantity < filters['min_qty']:
515
+ validation["valid"] = False
516
+ validation["errors"].append(f"Quantity {adjusted_quantity} below minimum {filters['min_qty']}")
517
+
518
+ if notional < min_notional:
519
+ validation["valid"] = False
520
+ validation["errors"].append(f"Notional {notional} below minimum {min_notional}")
521
+
522
+ return validation
523
+
524
+ def calculate_liquidation_price(
525
+ self,
526
+ entry_price: float,
527
+ leverage: int,
528
+ side: str,
529
+ maintenance_margin_rate: float = 0.005
530
+ ) -> float:
531
+ """Calculate approximate liquidation price."""
532
+ if side == "LONG":
533
+ liq_price = entry_price * (1 - (1 / leverage) + maintenance_margin_rate)
534
+ else:
535
+ liq_price = entry_price * (1 + (1 / leverage) - maintenance_margin_rate)
536
+
537
+ return liq_price
538
+
539
+ # ==================== Order History ====================
540
+
541
+ def get_all_orders(
542
+ self,
543
+ symbol: str,
544
+ start_time: int | None = None,
545
+ end_time: int | None = None,
546
+ limit: int = 500,
547
+ ) -> list[dict]: # type: ignore[type-arg]
548
+ """
549
+ Fetch historical order records.
550
+
551
+ Aster API: GET /fapi/v1/allOrders
552
+
553
+ Args:
554
+ symbol: Trading pair, e.g., "BTCUSDT"
555
+ start_time: Start timestamp (milliseconds)
556
+ end_time: End timestamp (milliseconds)
557
+ limit: Max records to return, default 500
558
+
559
+ Returns:
560
+ List of orders
561
+ """
562
+ params: dict = {"symbol": symbol} # type: ignore[type-arg]
563
+ if start_time:
564
+ params["startTime"] = start_time
565
+ if end_time:
566
+ params["endTime"] = end_time
567
+ if limit:
568
+ params["limit"] = min(limit, 1000)
569
+
570
+ return self._request("GET", "/fapi/v1/allOrders", signed=True, params=params)
571
+
572
+ def get_user_trades(
573
+ self,
574
+ symbol: str,
575
+ start_time: int | None = None,
576
+ end_time: int | None = None,
577
+ limit: int = 500,
578
+ ) -> list[dict]: # type: ignore[type-arg]
579
+ """
580
+ Fetch trade fill records.
581
+
582
+ Aster API: GET /fapi/v1/userTrades
583
+
584
+ Args:
585
+ symbol: Trading pair, e.g., "BTCUSDT"
586
+ start_time: Start timestamp (milliseconds)
587
+ end_time: End timestamp (milliseconds)
588
+ limit: Max records to return, default 500
589
+
590
+ Returns:
591
+ List of trades (includes id, price, qty, commission, realizedPnl)
592
+ """
593
+ params: dict = {"symbol": symbol} # type: ignore[type-arg]
594
+ if start_time:
595
+ params["startTime"] = start_time
596
+ if end_time:
597
+ params["endTime"] = end_time
598
+ if limit:
599
+ params["limit"] = min(limit, 1000)
600
+
601
+ return self._request("GET", "/fapi/v1/userTrades", signed=True, params=params)