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/base.py ADDED
@@ -0,0 +1,430 @@
1
+ """
2
+ Base class for futures exchange clients.
3
+
4
+ Provides common functionality shared across all exchange implementations:
5
+ - HTTP session management
6
+ - Request retry logic for rate limits
7
+ - Decimal formatting for prices/quantities
8
+ - Time synchronization
9
+ """
10
+
11
+ import random
12
+ import time
13
+ from abc import ABC, abstractmethod
14
+ from decimal import ROUND_DOWN, Decimal
15
+
16
+ import requests
17
+ from loguru import logger
18
+
19
+ # Optional function logging (may not be available in all environments)
20
+ try:
21
+ from .function_log import finish_function_call, record_function_call
22
+ _FUNCTION_LOG_AVAILABLE = True
23
+ except ImportError:
24
+ _FUNCTION_LOG_AVAILABLE = False
25
+
26
+
27
+ class BaseFuturesClient(ABC):
28
+ """
29
+ Abstract base class for futures exchange clients.
30
+
31
+ All exchange implementations should inherit from this class
32
+ and implement the abstract methods.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ api_key: str,
38
+ api_secret: str,
39
+ base_url: str,
40
+ max_retries: int = 5,
41
+ retry_delay: float = 1.0,
42
+ timeout: float = 5.0,
43
+ proxy_url: str | None = None
44
+ ):
45
+ """
46
+ Initialize base client.
47
+
48
+ Args:
49
+ api_key: API key
50
+ api_secret: API secret
51
+ base_url: API base URL
52
+ max_retries: Max retry attempts for rate limit errors
53
+ retry_delay: Base delay for exponential backoff
54
+ timeout: Request timeout in seconds
55
+ proxy_url: Optional proxy server URL for all requests.
56
+ Required for exchanges with IP whitelist restrictions.
57
+ Format: http://user:pass@host:port or http://host:port
58
+ """
59
+ if not api_key:
60
+ raise ValueError("api_key is required")
61
+ if not api_secret:
62
+ raise ValueError("api_secret is required")
63
+
64
+ self.api_key = api_key
65
+ self.api_secret = api_secret
66
+ self.base_url = base_url
67
+ self.max_retries = max_retries
68
+ self.retry_delay = retry_delay
69
+ self.timeout = timeout
70
+ self.proxy_url = proxy_url
71
+
72
+ self.session = requests.Session()
73
+ self._setup_session_headers()
74
+ self._setup_session_proxy()
75
+
76
+ # Caches
77
+ self._symbol_filters: dict = {}
78
+ self._leverage_brackets: dict = {}
79
+ self._last_sync_time: float = 0
80
+ self.time_offset: int = 0
81
+
82
+ def _setup_session_headers(self) -> None:
83
+ """Setup default session headers. Override in subclass if needed."""
84
+ self.session.headers.update({
85
+ "Content-Type": "application/json"
86
+ })
87
+
88
+ def _setup_session_proxy(self) -> None:
89
+ """Setup proxy for session if proxy_url is configured."""
90
+ if self.proxy_url:
91
+ self.session.proxies = {
92
+ "http": self.proxy_url,
93
+ "https": self.proxy_url,
94
+ }
95
+ # Mask sensitive parts of proxy URL for logging
96
+ masked_proxy = self._mask_proxy_url(self.proxy_url)
97
+ logger.info(f"Using proxy: {masked_proxy}")
98
+ else:
99
+ self.session.proxies = {}
100
+ logger.debug("No proxy configured, using direct connection")
101
+
102
+ def _mask_proxy_url(self, proxy_url: str) -> str:
103
+ """Mask sensitive parts of proxy URL for logging.
104
+
105
+ Args:
106
+ proxy_url: Full proxy URL like "http://user:pass@host:port" or "http://host:port"
107
+
108
+ Returns:
109
+ Masked URL with credentials hidden and IP partially masked
110
+ """
111
+ try:
112
+ # Parse proxy URL
113
+ if "@" in proxy_url:
114
+ # Format: http://user:pass@host:port
115
+ auth_part, endpoint = proxy_url.split("@", 1)
116
+ protocol = auth_part.split("://")[0]
117
+ # Mask credentials completely
118
+ return f"{protocol}://***@{self._mask_host(endpoint)}"
119
+ else:
120
+ # Format: http://host:port
121
+ protocol, endpoint = proxy_url.split("://", 1)
122
+ return f"{protocol}://{self._mask_host(endpoint)}"
123
+ except Exception:
124
+ # If parsing fails, return completely masked
125
+ return "***"
126
+
127
+ def _mask_host(self, endpoint: str) -> str:
128
+ """Mask IP address or hostname in endpoint.
129
+
130
+ Args:
131
+ endpoint: Endpoint like "192.168.1.1:8080" or "example.com:8080"
132
+
133
+ Returns:
134
+ Masked endpoint with IP partially hidden
135
+ """
136
+ # Split host and port
137
+ if ":" in endpoint:
138
+ host, port = endpoint.rsplit(":", 1)
139
+ # Mask IP address (show first and last octet)
140
+ parts = host.split(".")
141
+ if len(parts) == 4: # IPv4
142
+ return f"{parts[0]}.***.{parts[3]}:{port}"
143
+ else: # Domain or other format
144
+ return f"***:{port}"
145
+ else:
146
+ # No port, just mask host
147
+ parts = endpoint.split(".")
148
+ if len(parts) == 4: # IPv4
149
+ return f"{parts[0]}.***.{parts[3]}"
150
+ else:
151
+ return "***"
152
+
153
+ @abstractmethod
154
+ def _generate_signature(self, params: dict) -> str: # type: ignore[type-arg]
155
+ """Generate request signature. Must be implemented by subclass."""
156
+ pass
157
+
158
+ def _get_timestamp(self) -> int:
159
+ """Get current timestamp in milliseconds with offset correction."""
160
+ if time.time() - self._last_sync_time > 3600:
161
+ self._sync_server_time()
162
+ return int(time.time() * 1000) + self.time_offset
163
+
164
+ def _get_server_time_endpoint(self) -> str:
165
+ """
166
+ Get server time endpoint. Override in subclass for different exchanges.
167
+ Return empty string to disable time sync.
168
+ """
169
+ return "/fapi/v1/time"
170
+
171
+ def _parse_server_time(self, response_data: dict) -> int: # type: ignore[type-arg]
172
+ """Parse server time from response. Override if response format differs."""
173
+ return response_data.get('serverTime', int(time.time() * 1000))
174
+
175
+ def _sync_server_time(self) -> None:
176
+ """Sync with server time."""
177
+ endpoint = self._get_server_time_endpoint()
178
+ if not endpoint:
179
+ # Time sync disabled for this exchange
180
+ return
181
+
182
+ try:
183
+ response = self.session.get(
184
+ f"{self.base_url}{endpoint}",
185
+ timeout=self.timeout
186
+ )
187
+ if response.status_code == 200:
188
+ server_time = self._parse_server_time(response.json())
189
+ local_time = int(time.time() * 1000)
190
+ self.time_offset = server_time - local_time
191
+ self._last_sync_time = time.time()
192
+ except Exception as e:
193
+ logger.warning(f"Clock sync failed: {e}")
194
+ self.time_offset = 0
195
+
196
+ def _request(
197
+ self,
198
+ method: str,
199
+ endpoint: str,
200
+ signed: bool = False,
201
+ **kwargs: dict # type: ignore[type-arg]
202
+ ) -> dict: # type: ignore[type-arg]
203
+ """
204
+ Generic request method with retry logic for rate limits.
205
+
206
+ Args:
207
+ method: HTTP method (GET/POST/DELETE)
208
+ endpoint: API endpoint
209
+ signed: Whether signature is required
210
+ **kwargs: Additional request parameters
211
+
212
+ Returns:
213
+ Response data as dict
214
+
215
+ Raises:
216
+ requests.exceptions.HTTPError: For non-retryable HTTP errors
217
+ """
218
+ url = f"{self.base_url}{endpoint}"
219
+
220
+ # Record function call start (if logging is available)
221
+ if _FUNCTION_LOG_AVAILABLE:
222
+ function_name = f"{method.lower()}_{endpoint.replace('/', '_')}"
223
+ record_params = {"endpoint": endpoint, "method": method}
224
+ if 'params' in kwargs:
225
+ record_params.update(kwargs['params'])
226
+ record_function_call(function_name, record_params)
227
+
228
+ for attempt in range(self.max_retries + 1):
229
+ if signed:
230
+ params = kwargs.get('params', {})
231
+ if 'signature' in params:
232
+ del params['signature']
233
+ params['timestamp'] = self._get_timestamp()
234
+ params['recvWindow'] = 5000
235
+ params['signature'] = self._generate_signature(params)
236
+ kwargs['params'] = params
237
+
238
+ try:
239
+ if 'timeout' not in kwargs:
240
+ kwargs['timeout'] = self.timeout
241
+
242
+ response = self.session.request(method, url, **kwargs)
243
+ response.raise_for_status()
244
+ result = response.json()
245
+
246
+ # Record successful function call
247
+ if _FUNCTION_LOG_AVAILABLE:
248
+ finish_function_call(function_name, {"status": "succeeded", "data": result})
249
+
250
+ return result
251
+
252
+ except requests.exceptions.HTTPError as e:
253
+ resp = e.response
254
+
255
+ if resp.status_code == 429:
256
+ if attempt < self.max_retries:
257
+ retry_after = resp.headers.get('Retry-After')
258
+ if retry_after:
259
+ try:
260
+ delay = float(retry_after)
261
+ except ValueError:
262
+ delay = self.retry_delay * (2 ** attempt)
263
+ else:
264
+ delay = self.retry_delay * (2 ** attempt)
265
+
266
+ jitter = delay * 0.2 * (2 * random.random() - 1)
267
+ delay = delay + jitter
268
+
269
+ logger.warning(
270
+ f"Rate limit hit (429) on {method} {endpoint}. "
271
+ f"Retry {attempt + 1}/{self.max_retries} after {delay:.2f}s"
272
+ )
273
+ time.sleep(delay)
274
+ continue
275
+ else:
276
+ logger.error(
277
+ f"Rate limit exceeded after {self.max_retries} retries"
278
+ )
279
+ logger.error(f"Response: {resp.text}")
280
+ if _FUNCTION_LOG_AVAILABLE:
281
+ finish_function_call(function_name, error=f"Rate limit exceeded")
282
+ raise
283
+
284
+ logger.error(f"API request failed: {e}")
285
+ logger.error(f"Response: {resp.text}")
286
+ if _FUNCTION_LOG_AVAILABLE:
287
+ finish_function_call(function_name, error=str(e))
288
+ raise
289
+
290
+ except requests.exceptions.Timeout as e:
291
+ logger.error(f"Request timeout: {method} {endpoint}")
292
+ if _FUNCTION_LOG_AVAILABLE:
293
+ finish_function_call(function_name, error=f"Timeout after {self.timeout}s")
294
+ raise Exception(f"API request timeout after {self.timeout}s") from e
295
+
296
+ except requests.exceptions.ConnectionError as e:
297
+ logger.error(f"Connection error: {method} {endpoint} - {e}")
298
+ if _FUNCTION_LOG_AVAILABLE:
299
+ finish_function_call(function_name, error=f"Connection error: {e}")
300
+ raise Exception(f"API connection failed: {e}") from e
301
+
302
+ except Exception as e:
303
+ logger.error(f"Request exception: {method} {endpoint} - {e}")
304
+ if _FUNCTION_LOG_AVAILABLE:
305
+ finish_function_call(function_name, error=str(e))
306
+ raise
307
+
308
+ if _FUNCTION_LOG_AVAILABLE:
309
+ finish_function_call(function_name, error="Exhausted all retry attempts")
310
+ raise Exception(f"Exhausted all retry attempts for {method} {endpoint}")
311
+
312
+ def _format_decimal(
313
+ self,
314
+ value: float,
315
+ step: float | None = None,
316
+ precision: int | None = None,
317
+ rounding: str = ROUND_DOWN
318
+ ) -> str:
319
+ """Format numeric values to comply with exchange precision rules."""
320
+ if value is None:
321
+ return ""
322
+ decimal_value = Decimal(str(value))
323
+
324
+ if step:
325
+ step_decimal = Decimal(str(step))
326
+ if step_decimal > 0:
327
+ multiple = (decimal_value / step_decimal).to_integral_value(rounding=rounding)
328
+ decimal_value = (multiple * step_decimal).quantize(step_decimal, rounding=rounding)
329
+
330
+ if precision is not None and precision >= 0:
331
+ quant = Decimal('1').scaleb(-precision)
332
+ decimal_value = decimal_value.quantize(quant, rounding=rounding)
333
+
334
+ normalized = decimal_value.normalize()
335
+ return format(normalized, 'f')
336
+
337
+ # ==================== Abstract Methods ====================
338
+ # Subclasses must implement these methods
339
+
340
+ @classmethod
341
+ @abstractmethod
342
+ def from_config(cls, config: "ExchangeConfig") -> "BaseFuturesClient": # type: ignore[name-defined] # noqa: F821
343
+ """Create client instance from ExchangeConfig."""
344
+ pass
345
+
346
+ # Market Data
347
+ @abstractmethod
348
+ def get_klines(self, symbol: str, interval: str = "1h", limit: int = 200) -> list[dict]: # type: ignore[type-arg]
349
+ """Fetch candlestick data."""
350
+ pass
351
+
352
+ @abstractmethod
353
+ def get_mark_price(self, symbol: str) -> dict: # type: ignore[type-arg]
354
+ """Fetch mark price info."""
355
+ pass
356
+
357
+ @abstractmethod
358
+ def get_depth(self, symbol: str, limit: int = 20) -> dict: # type: ignore[type-arg]
359
+ """Fetch orderbook."""
360
+ pass
361
+
362
+ @abstractmethod
363
+ def get_ticker_24hr(self, symbol: str) -> dict: # type: ignore[type-arg]
364
+ """Fetch 24h statistics."""
365
+ pass
366
+
367
+ # Account
368
+ @abstractmethod
369
+ def get_account(self) -> dict: # type: ignore[type-arg]
370
+ """Fetch account info."""
371
+ pass
372
+
373
+ @abstractmethod
374
+ def get_positions(self, symbol: str | None = None) -> list[dict]: # type: ignore[type-arg]
375
+ """Fetch open positions."""
376
+ pass
377
+
378
+ @abstractmethod
379
+ def get_balance(self) -> dict: # type: ignore[type-arg]
380
+ """Fetch balance summary."""
381
+ pass
382
+
383
+ # Trading
384
+ @abstractmethod
385
+ def set_leverage(self, symbol: str, leverage: int) -> dict: # type: ignore[type-arg]
386
+ """Set leverage for symbol."""
387
+ pass
388
+
389
+ @abstractmethod
390
+ def place_order(
391
+ self,
392
+ symbol: str,
393
+ side: str,
394
+ order_type: str = "LIMIT",
395
+ quantity: float | None = None,
396
+ price: float | None = None,
397
+ stop_price: float | None = None,
398
+ reduce_only: bool = False,
399
+ time_in_force: str = "GTC",
400
+ client_order_id: str | None = None,
401
+ **kwargs: dict # type: ignore[type-arg]
402
+ ) -> dict: # type: ignore[type-arg]
403
+ """Place an order."""
404
+ pass
405
+
406
+ @abstractmethod
407
+ def cancel_order(
408
+ self,
409
+ symbol: str,
410
+ order_id: int | None = None,
411
+ client_order_id: str | None = None
412
+ ) -> dict: # type: ignore[type-arg]
413
+ """Cancel an order."""
414
+ pass
415
+
416
+ @abstractmethod
417
+ def get_open_orders(self, symbol: str | None = None) -> list[dict]: # type: ignore[type-arg]
418
+ """Get open orders."""
419
+ pass
420
+
421
+ # Helpers
422
+ @abstractmethod
423
+ def get_symbol_filters(self, symbol: str, force_refresh: bool = False) -> dict: # type: ignore[type-arg]
424
+ """Get trading rules for symbol."""
425
+ pass
426
+
427
+ @abstractmethod
428
+ def close_position(self, symbol: str, percent: float = 100.0) -> dict: # type: ignore[type-arg]
429
+ """Close position by percentage."""
430
+ pass
@@ -0,0 +1,34 @@
1
+ """
2
+ Exchange configuration model.
3
+
4
+ Provides type-safe configuration for exchange clients.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+
10
+
11
+ @dataclass
12
+ class ExchangeConfig:
13
+ """
14
+ Exchange configuration.
15
+
16
+ Supported exchanges: asterdex, weex, binance, okx (more to come)
17
+
18
+ Attributes:
19
+ name: Exchange name ("asterdex", "weex", etc.)
20
+ api_key: API key
21
+ api_secret: API secret
22
+ base_url: API base URL (auto-detected if empty)
23
+ passphrase: API passphrase (required for WEEX, OKX)
24
+ proxy_url: Proxy server URL for API requests (optional).
25
+ Required for exchanges with IP whitelist restrictions.
26
+ Format: http://user:pass@host:port or http://host:port
27
+ If not provided, connects directly to exchange.
28
+ """
29
+ name: str
30
+ api_key: str
31
+ api_secret: str
32
+ base_url: str = ""
33
+ passphrase: str = ""
34
+ proxy_url: Optional[str] = None