pybinbot 0.4.0__py3-none-any.whl → 0.4.15__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.
pybinbot/__init__.py CHANGED
@@ -47,7 +47,7 @@ from pybinbot.shared.enums import (
47
47
  from pybinbot.shared.indicators import Indicators
48
48
  from pybinbot.shared.heikin_ashi import HeikinAshi
49
49
  from pybinbot.shared.logging_config import configure_logging
50
- from pybinbot.shared.types import Amount
50
+ from pybinbot.shared.types import Amount, CombinedApis
51
51
  from pybinbot.shared.cache import cache
52
52
  from pybinbot.shared.handlers import handle_binance_errors, aio_response_handler
53
53
  from pybinbot.models.bot_base import BotBase
@@ -78,12 +78,13 @@ from pybinbot.apis.binance.exceptions import (
78
78
  )
79
79
 
80
80
 
81
- from . import models, shared
81
+ from . import models, shared, apis
82
82
 
83
83
  __all__ = [
84
84
  # subpackages
85
85
  "shared",
86
86
  "models",
87
+ "apis",
87
88
  # models
88
89
  "BotBase",
89
90
  "OrderBase",
@@ -93,6 +94,7 @@ __all__ = [
93
94
  "SignalsConsumer",
94
95
  # misc
95
96
  "Amount",
97
+ "CombinedApis",
96
98
  "configure_logging",
97
99
  "cache",
98
100
  "handle_binance_errors",
@@ -0,0 +1,588 @@
1
+ import hashlib
2
+ import hmac
3
+ from decimal import Decimal
4
+ from random import randrange
5
+ from urllib.parse import urlencode
6
+ from requests import Session, request, HTTPError
7
+ from pybinbot.shared.handlers import handle_binance_errors
8
+ from pybinbot.shared.cache import cache
9
+ from pybinbot.apis.binbot.exceptions import IsolateBalanceError
10
+
11
+
12
+ class BinanceApi:
13
+ """
14
+ Binance API URLs
15
+ https://binance.github.io/binance-api-swagger/
16
+ """
17
+
18
+ api_servers = [
19
+ "https://api.binance.com",
20
+ "https://api1.binance.com",
21
+ "https://api3.binance.com",
22
+ "https://api-gcp.binance.com",
23
+ ]
24
+ market_api_servers = ["https://data-api.binance.vision", "https://api3.binance.com"]
25
+ BASE = api_servers[randrange(2) - 1]
26
+ MARKET_DATA_BASE = market_api_servers[randrange(3) - 1]
27
+ WAPI = f"{BASE}/api/v3/depth"
28
+ WS_BASE = "wss://stream.binance.com:9443/stream?streams="
29
+ tags_url = "https://www.binance.com/bapi/asset/v2/public/asset-service/product/get-product-by-symbol"
30
+
31
+ recvWindow = 9000
32
+ server_time_url = f"{MARKET_DATA_BASE}/api/v3/time"
33
+ # Binance always returning forbidden for other APIs
34
+ account_url = f"{api_servers[1]}/api/v3/account"
35
+ exchangeinfo_url = f"{MARKET_DATA_BASE}/api/v3/exchangeInfo"
36
+ ticker_price_url = f"{MARKET_DATA_BASE}/api/v3/ticker/price"
37
+ ticker24_url = f"{MARKET_DATA_BASE}/api/v3/ticker/24hr"
38
+ candlestick_url = "https://api3.binance.com/api/v3/uiKlines"
39
+ order_url = f"{BASE}/api/v3/order"
40
+ order_book_url = f"{BASE}/api/v3/depth"
41
+ avg_price = f"{BASE}/api/v3/avgPrice"
42
+ open_orders = f"{BASE}/api/v3/openOrders"
43
+ all_orders_url = f"{BASE}/api/v3/allOrders"
44
+ cancel_replace_url = f"{BASE}/api/v3/order/cancelReplace"
45
+ trade_fee = f"{BASE}/sapi/v1/asset/tradeFee"
46
+ wallet_balance_url = f"{BASE}/sapi/v1/asset/wallet/balance"
47
+ user_asset_url = f"{BASE}/sapi/v3/asset/getUserAsset"
48
+
49
+ # order, user data, only works with api.binance host
50
+ user_data_stream = "https://api.binance.com/api/v3/userDataStream"
51
+
52
+ withdraw_url = f"{BASE}/wapi/v3/withdraw.html"
53
+ withdraw_history_url = f"{BASE}/wapi/v3/withdrawHistory.html"
54
+ deposit_history_url = f"{BASE}/wapi/v3/depositHistory.html"
55
+ deposit_address_url = f"{BASE}/wapi/v3/depositAddress.html"
56
+
57
+ dust_transfer_url = f"{BASE}/sapi/v1/asset/dust"
58
+ account_snapshot_url = f"{BASE}/sapi/v1/accountSnapshot"
59
+
60
+ # Margin
61
+ isolated_fee_url = f"{BASE}/sapi/v1/margin/isolatedMarginData"
62
+ isolated_account_url = f"{BASE}/sapi/v1/margin/isolated/account"
63
+ margin_isolated_transfer_url = f"{BASE}/sapi/v1/margin/isolated/transfer"
64
+ loan_record_url = f"{BASE}/sapi/v1/margin/borrow-repay"
65
+ isolated_hourly_interest = f"{BASE}/sapi/v1/margin/next-hourly-interest-rate"
66
+ margin_order = f"{BASE}/sapi/v1/margin/order"
67
+ max_borrow_url = f"{BASE}/sapi/v1/margin/maxBorrowable"
68
+ interest_history_url = f"{BASE}/sapi/v1/margin/interestHistory"
69
+ manual_liquidation_url = f"{BASE}/sapi/v1/margin/manual-liquidation"
70
+
71
+ def __init__(self, key, secret) -> None:
72
+ self.secret: str = secret
73
+ self.key: str = key
74
+ pass
75
+
76
+ def request(
77
+ self,
78
+ url,
79
+ method="GET",
80
+ session: Session | None = None,
81
+ payload: dict | None = None,
82
+ **kwargs,
83
+ ):
84
+ """
85
+ Standard request
86
+ - No signed
87
+ - No authorization
88
+ """
89
+ if session:
90
+ res = session.request(method=method, url=url, **kwargs)
91
+ else:
92
+ res = request(method=method, url=url, json=payload, **kwargs)
93
+ data = handle_binance_errors(res)
94
+ return data
95
+
96
+ def get_server_time(self):
97
+ response = self.request(url=self.server_time_url)
98
+ data = handle_binance_errors(response)
99
+ return data["serverTime"]
100
+
101
+ def signed_request(self, url, method="GET", payload: dict = {}) -> dict:
102
+ """
103
+ USER_DATA, TRADE signed requests
104
+
105
+ Arguments are all the same as requests
106
+ except payload, which is centrally formatted
107
+ here to become a JSON
108
+ """
109
+ session = Session()
110
+ query_string = urlencode(payload, True)
111
+ timestamp = self.get_server_time()
112
+ session.headers.update(
113
+ {"Content-Type": "application/json", "X-MBX-APIKEY": self.key}
114
+ )
115
+
116
+ if query_string:
117
+ query_string = (
118
+ f"{query_string}&recvWindow={self.recvWindow}&timestamp={timestamp}"
119
+ )
120
+ else:
121
+ query_string = f"recvWindow={self.recvWindow}&timestamp={timestamp}"
122
+
123
+ signature = hmac.new(
124
+ self.secret.encode("utf-8"),
125
+ query_string.encode("utf-8"),
126
+ hashlib.sha256,
127
+ ).hexdigest()
128
+ url = f"{url}?{query_string}&signature={signature}"
129
+ data = self.request(url, method, session)
130
+ return data
131
+
132
+ def get_listen_key(self):
133
+ """
134
+ Get user data websocket stream
135
+ """
136
+ headers = {"Content-Type": "application/json", "X-MBX-APIKEY": self.key}
137
+ res = request(method="POST", url=self.user_data_stream, headers=headers)
138
+ response = handle_binance_errors(res)
139
+ listen_key = response["listenKey"]
140
+ return listen_key
141
+
142
+ """
143
+ No security endpoints
144
+ """
145
+
146
+ def exchange_info(self, symbol=None):
147
+ """
148
+ This must be a separate method because classes use it with inheritance
149
+
150
+ This request is used in many places to retrieve data about symbols, precisions etc.
151
+ It is a high weight endpoint, thus Binance could ban our IP
152
+ However it is not real-time updated data, so cache is used to avoid hitting endpoint
153
+ too many times and still be able to re-request data everywhere.
154
+
155
+ In addition, it uses MongoDB, with a separate database called "mongo_cache"
156
+ """
157
+ params = {}
158
+ if symbol:
159
+ params["symbol"] = symbol
160
+
161
+ # mongo_cache = self.setup_mongocache()
162
+ # set up a cache that expires in 1440'' (24hrs)
163
+ # session = CachedSession("http_cache", backend=mongo_cache, expire_after=1440)
164
+ exchange_info_res = self.request(url=f"{self.exchangeinfo_url}", params=params)
165
+ return exchange_info_res
166
+
167
+ def price_filter_by_symbol(self, symbol, filter_limit):
168
+ """
169
+ PRICE_FILTER restrictions from /exchangeinfo
170
+ @params:
171
+ - symbol: string - pair/market e.g. BNBBTC
172
+ - filter_limit: string - minPrice or maxPrice
173
+ """
174
+ symbols = self.exchange_info(symbol)
175
+ market = symbols["symbols"][0]
176
+ price_filter = next(
177
+ (m for m in market["filters"] if m["filterType"] == "PRICE_FILTER")
178
+ )
179
+ return price_filter[filter_limit].rstrip(".0")
180
+
181
+ def lot_size_by_symbol(self, symbol, lot_size_limit):
182
+ """
183
+ LOT_SIZE (quantity) restrictions from /exchangeinfo
184
+ @params:
185
+ - symbol: string - pair/market e.g. BNBBTC
186
+ - lot_size_limit: string - minQty, maxQty, stepSize
187
+ """
188
+ symbols = self.exchange_info(symbol)
189
+ market = symbols["symbols"][0]
190
+ quantity_filter = next(
191
+ (m for m in market["filters"] if m["filterType"] == "LOT_SIZE")
192
+ )
193
+ return quantity_filter[lot_size_limit].rstrip(".0")
194
+
195
+ def min_notional_by_symbol(self, symbol, min_notional_limit="minNotional"):
196
+ """
197
+ MIN_NOTIONAL (price x quantity) restrictions
198
+ from Binance /exchangeinfo
199
+ @deprecated
200
+ @params:
201
+ - symbol: string - pair/market e.g. BNBBTC
202
+ - min_notional_limit: string - minNotional
203
+ """
204
+ symbols = self.exchange_info(symbol)
205
+ market = symbols["symbols"][0]
206
+ min_notional_filter = next(
207
+ m for m in market["filters"] if m["filterType"] == "NOTIONAL"
208
+ )
209
+ return min_notional_filter[min_notional_limit]
210
+
211
+ def _calculate_price_precision(self, symbol) -> int:
212
+ """
213
+ Decimals needed for Binance price
214
+ @deprecated - use calculate_price_precision
215
+ """
216
+ precision = -1 * (
217
+ Decimal(str(self.price_filter_by_symbol(symbol, "tickSize")))
218
+ .as_tuple()
219
+ .exponent
220
+ )
221
+ price_precision = int(precision)
222
+ return price_precision
223
+
224
+ def _calculate_qty_precision(self, symbol) -> int:
225
+ """
226
+ Decimals needed for Binance quantity
227
+ @deprecated - use calculate_qty_precision
228
+ """
229
+ precision = -1 * (
230
+ Decimal(str(self.lot_size_by_symbol(symbol, "stepSize")))
231
+ .as_tuple()
232
+ .exponent
233
+ )
234
+ qty_precision = int(precision)
235
+ return qty_precision
236
+
237
+ def ticker_24(self, type: str = "FULL", symbol: str | None = None):
238
+ """
239
+ Weight 40 without symbol
240
+ https://github.com/carkod/binbot/issues/438
241
+
242
+ Cannot use cache, because data would be stale
243
+ """
244
+ params = {"type": type}
245
+ if symbol:
246
+ params["symbol"] = symbol
247
+
248
+ data = self.request(url=self.ticker24_url, params=params)
249
+ return data
250
+
251
+ def get_ticker_price(self, symbol: str) -> float:
252
+ data = self.request(url=f"{self.ticker_price_url}", params={"symbol": symbol})
253
+ return float(data["price"])
254
+
255
+ def get_ui_klines(
256
+ self, symbol, interval, limit=500, start_time=None, end_time=None
257
+ ):
258
+ """
259
+ Get raw klines
260
+ """
261
+ params = {
262
+ "symbol": symbol,
263
+ "interval": interval,
264
+ "limit": limit,
265
+ }
266
+ if start_time:
267
+ params["startTime"] = start_time
268
+ if end_time:
269
+ params["endTime"] = end_time
270
+
271
+ data = self.request(url=self.candlestick_url, params=params)
272
+ return data
273
+
274
+ """
275
+ USER_DATA endpoints
276
+ """
277
+
278
+ def get_account_balance(self):
279
+ """
280
+ Get account balance
281
+ """
282
+ payload = {"omitZeroBalances": "true"}
283
+ data = self.signed_request(self.account_url, payload=payload)
284
+ return data
285
+
286
+ def get_wallet_balance(self):
287
+ """
288
+ Balance by wallet (SPOT, FUNDING, CROSS MARGIN...)
289
+ https://binance-docs.github.io/apidocs/spot/en/#query-user-wallet-balance-user_data
290
+
291
+ This is a consolidated balance across all account
292
+ so it doesn't require us to retrieve isolated margin, cross margin, etc, separately.
293
+ """
294
+ data = self.signed_request(self.wallet_balance_url)
295
+ return data
296
+
297
+ def cancel_margin_order(self, symbol: str, order_id: int):
298
+ return self.signed_request(
299
+ self.margin_order,
300
+ method="DELETE",
301
+ payload={"symbol": symbol, "orderId": str(order_id)},
302
+ )
303
+
304
+ def enable_isolated_margin_account(self, symbol):
305
+ return self.signed_request(
306
+ self.isolated_account_url, method="POST", payload={"symbol": symbol}
307
+ )
308
+
309
+ def disable_isolated_margin_account(self, symbol):
310
+ """
311
+ Very high weight, use as little as possible
312
+
313
+ There is a cronjob that disables all margin isolated accounts everyday
314
+ check market_updates
315
+ """
316
+ return self.signed_request(
317
+ self.isolated_account_url, method="DELETE", payload={"symbol": symbol}
318
+ )
319
+
320
+ def get_isolated_account(self, symbol):
321
+ """
322
+ https://developers.binance.com/docs/margin_trading/account/Query-Isolated-Margin-Account-Info
323
+ Request weight: 10(IP)
324
+ """
325
+ return self.signed_request(
326
+ self.isolated_account_url, payload={"symbol": symbol}
327
+ )
328
+
329
+ def transfer_isolated_margin_to_spot(self, asset, symbol, amount):
330
+ return self.signed_request(
331
+ self.margin_isolated_transfer_url,
332
+ method="POST",
333
+ payload={
334
+ "transFrom": "ISOLATED_MARGIN",
335
+ "transTo": "SPOT",
336
+ "asset": asset,
337
+ "symbol": symbol,
338
+ "amount": amount,
339
+ },
340
+ )
341
+
342
+ def transfer_spot_to_isolated_margin(self, asset: str, symbol: str, amount: float):
343
+ return self.signed_request(
344
+ self.margin_isolated_transfer_url,
345
+ method="POST",
346
+ payload={
347
+ "transFrom": "SPOT",
348
+ "transTo": "ISOLATED_MARGIN",
349
+ "asset": asset,
350
+ "symbol": symbol,
351
+ "amount": str(amount),
352
+ },
353
+ )
354
+
355
+ def create_margin_loan(self, asset, symbol, amount, isIsolated=True):
356
+ if not isIsolated:
357
+ isIsolated = "FALSE"
358
+ else:
359
+ isIsolated = "TRUE"
360
+
361
+ return self.signed_request(
362
+ self.loan_record_url,
363
+ method="POST",
364
+ payload={
365
+ "asset": asset,
366
+ "symbol": symbol,
367
+ "amount": amount,
368
+ "isIsolated": isIsolated,
369
+ "type": "BORROW",
370
+ },
371
+ )
372
+
373
+ def get_max_borrow(self, asset, isolated_symbol: str | None = None):
374
+ return self.signed_request(
375
+ self.max_borrow_url,
376
+ payload={"asset": asset, "isolatedSymbol": isolated_symbol},
377
+ )
378
+
379
+ def get_margin_loan_details(self, loan_id: int, symbol: str):
380
+ return self.signed_request(
381
+ self.loan_record_url,
382
+ payload={
383
+ "txId": loan_id,
384
+ "type": "BORROW",
385
+ "isolatedSymbol": symbol,
386
+ },
387
+ )
388
+
389
+ def get_repay_details(self, loan_id: int, symbol: str):
390
+ return self.signed_request(
391
+ self.loan_record_url,
392
+ payload={
393
+ "txId": loan_id,
394
+ "type": "REPAY",
395
+ "isolatedSymbol": symbol,
396
+ },
397
+ )
398
+
399
+ def repay_margin_loan(
400
+ self, asset: str, symbol: str, amount: float | int, isIsolated: str = "TRUE"
401
+ ):
402
+ return self.signed_request(
403
+ self.loan_record_url,
404
+ method="POST",
405
+ payload={
406
+ "asset": asset,
407
+ "isIsolated": isIsolated,
408
+ "symbol": symbol,
409
+ "amount": amount,
410
+ "type": "REPAY",
411
+ },
412
+ )
413
+
414
+ def manual_liquidation(self, symbol: str):
415
+ """
416
+ Not supported in region
417
+ """
418
+ return self.signed_request(
419
+ self.manual_liquidation_url,
420
+ method="POST",
421
+ payload={
422
+ "symbol": symbol,
423
+ "type": "ISOLATED",
424
+ },
425
+ )
426
+
427
+ def get_interest_history(self, asset: str, symbol: str):
428
+ return self.signed_request(
429
+ self.interest_history_url,
430
+ payload={"asset": asset, "isolatedSymbol": symbol},
431
+ )
432
+
433
+ def get_isolated_balance(self, symbol=None) -> list:
434
+ """
435
+ Get balance of Isolated Margin account
436
+
437
+ Use isolated margin account is preferrable,
438
+ because this is the one that supports the most assets
439
+ """
440
+ payload = {}
441
+ if symbol:
442
+ payload["symbols"] = [symbol]
443
+ info = self.signed_request(url=self.isolated_account_url, payload=payload)
444
+ assets = info["assets"]
445
+ return assets
446
+
447
+ def get_isolated_balance_total(self):
448
+ """
449
+ Get balance of Isolated Margin account
450
+
451
+ Use isolated margin account is preferrable,
452
+ because this is the one that supports the most assets
453
+ """
454
+ info = self.signed_request(url=self.isolated_account_url, payload={})
455
+ assets = info["totalNetAssetOfBtc"]
456
+ if len(assets) == 0:
457
+ raise IsolateBalanceError(
458
+ "Hit symbol 24hr restriction or not available (requires transfer in)"
459
+ )
460
+ return assets
461
+
462
+ def transfer_dust(self, assets: list[str]):
463
+ """
464
+ Transform small balances to BNB
465
+ """
466
+ list_assets = ",".join(assets)
467
+ response = self.signed_request(
468
+ url=self.dust_transfer_url, method="POST", payload={"asset": list_assets}
469
+ )
470
+ return response
471
+
472
+ def query_open_orders(self, symbol):
473
+ """
474
+ Get current open orders
475
+
476
+ This is a high weight endpoint IP Weight: 20
477
+ https://binance-docs.github.io/apidocs/spot/en/#current-open-orders-user_data
478
+ """
479
+ open_orders = self.signed_request(self.open_orders, payload={"symbol": symbol})
480
+ return open_orders
481
+
482
+ def get_all_orders(self, symbol, order_id: str | None = None, start_time=None):
483
+ """
484
+ Get all orders given symbol and order_id
485
+
486
+ This is a high weight endpoint IP Weight: 20
487
+ https://binance-docs.github.io/apidocs/spot/en/#all-orders-user_data
488
+
489
+ Args:
490
+ - symbol: str
491
+ - order_id: int
492
+ - start_time
493
+ - end_time
494
+
495
+ At least one of order_id or (start_time and end_time) must be sent
496
+ """
497
+ if order_id:
498
+ return self.signed_request(
499
+ self.all_orders_url, payload={"symbol": symbol, "orderId": order_id}
500
+ )
501
+
502
+ elif start_time:
503
+ return self.signed_request(
504
+ self.all_orders_url, payload={"symbol": symbol, "startTime": start_time}
505
+ )
506
+
507
+ else:
508
+ raise ValueError(
509
+ "At least one of order_id or (start_time and end_time) must be sent"
510
+ )
511
+
512
+ def delete_opened_order(self, symbol, order_id):
513
+ """
514
+ Cancel single order
515
+ """
516
+ return self.signed_request(
517
+ self.order_url,
518
+ method="DELETE",
519
+ payload={"symbol": symbol, "orderId": order_id},
520
+ )
521
+
522
+ def get_book_depth(self, symbol: str) -> dict:
523
+ """
524
+ Get order book for a given symbol
525
+ """
526
+ data = self.request(url=f"{self.order_book_url}?symbol={symbol}")
527
+ return data
528
+
529
+ def get_user_asset(self, asset: str, need_btc_valuation: bool = False):
530
+ """
531
+ Get user asset
532
+
533
+ https://developers.binance.com/docs/wallet/asset/user-assets
534
+ response:
535
+ {
536
+ "asset": "AVAX",
537
+ "free": "1",
538
+ "locked": "0",
539
+ "freeze": "0",
540
+ "withdrawing": "0",
541
+ "ipoable": "0",
542
+ "btcValuation": "0"
543
+ },
544
+ """
545
+ data = self.signed_request(
546
+ url=self.user_asset_url,
547
+ method="POST",
548
+ payload={"asset": asset, "needBtcValuation": need_btc_valuation},
549
+ )
550
+ return data
551
+
552
+ def ticker_24_pct_change(
553
+ self, symbol: str = "BTCUSDC", type: str = "FULL"
554
+ ) -> float:
555
+ """Return only the last price from Binance 24h ticker for a symbol.
556
+ Default symbol is BTCUSDC to match project usage elsewhere.
557
+ """
558
+ data = self.ticker_24(type=type, symbol=symbol)
559
+ try:
560
+ last_price = float(
561
+ data["priceChangePercent"]
562
+ ) # Binance returns string numbers
563
+ return last_price
564
+ except Exception as e:
565
+ raise RuntimeError(f"Failed to get last price for {symbol}: {e}")
566
+
567
+ def ticker_24_last_price_cached(
568
+ self,
569
+ ttl_seconds: int = 3600,
570
+ ) -> float:
571
+ """Cached version of ticker_24_last_price with TTL (per-process cache).
572
+ Adjust ttl_seconds as needed (e.g., 30*3600 for 30 hours).
573
+ """
574
+
575
+ @cache(ttl_seconds=ttl_seconds)
576
+ def _cached() -> float:
577
+ return self.ticker_24_pct_change(symbol="BTCUSDC", type="FULL")
578
+
579
+ return _cached()
580
+
581
+ def get_tags(self, symbol: str) -> dict:
582
+ """
583
+ Get tags for a specific symbol.
584
+ """
585
+ response = self.request(self.tags_url, params={"symbol": symbol})
586
+ if response["success"]:
587
+ return response["data"]
588
+ raise HTTPError(response["message"], response=response)
@@ -0,0 +1,17 @@
1
+ class BinanceErrors(Exception):
2
+ def __init__(self, msg, code):
3
+ self.code = code
4
+ self.message = msg
5
+ super().__init__(self.code, self.message)
6
+ return None
7
+
8
+ def __str__(self) -> str:
9
+ return f"{self.code} {self.message}"
10
+
11
+
12
+ class InvalidSymbol(BinanceErrors):
13
+ pass
14
+
15
+
16
+ class NotEnoughFunds(BinanceErrors):
17
+ pass