diff --git a/hummingbot/client/config/client_config_map.py b/hummingbot/client/config/client_config_map.py index e92b90b114..d3f022efee 100644 --- a/hummingbot/client/config/client_config_map.py +++ b/hummingbot/client/config/client_config_map.py @@ -768,6 +768,17 @@ class Config: title = "binance" +class BinanceUSRateSourceMode(ExchangeRateSourceModeBase): + name: str = Field( + default="binance_us", + const=True, + client_data=None, + ) + + class Config: + title = "binance_us" + + class CubeRateSourceMode(ExchangeRateSourceModeBase): name: str = Field( default="cube", @@ -935,6 +946,7 @@ class Config: RATE_SOURCE_MODES = { AscendExRateSourceMode.Config.title: AscendExRateSourceMode, BinanceRateSourceMode.Config.title: BinanceRateSourceMode, + BinanceUSRateSourceMode.Config.title: BinanceUSRateSourceMode, CoinGeckoRateSourceMode.Config.title: CoinGeckoRateSourceMode, CoinCapRateSourceMode.Config.title: CoinCapRateSourceMode, KuCoinRateSourceMode.Config.title: KuCoinRateSourceMode, diff --git a/hummingbot/connector/exchange/binance/binance_utils.py b/hummingbot/connector/exchange/binance/binance_utils.py index b0e47c4da0..6b3676066d 100644 --- a/hummingbot/connector/exchange/binance/binance_utils.py +++ b/hummingbot/connector/exchange/binance/binance_utils.py @@ -22,7 +22,20 @@ def is_exchange_information_valid(exchange_info: Dict[str, Any]) -> bool: :param exchange_info: the exchange information for a trading pair :return: True if the trading pair is enabled, False otherwise """ - return exchange_info.get("status", None) == "TRADING" and "SPOT" in exchange_info.get("permissions", list()) + is_spot = False + is_trading = False + + if exchange_info.get("status", None) == "TRADING": + is_trading = True + + permissions_sets = exchange_info.get("permissionSets", list()) + for permission_set in permissions_sets: + # PermissionSet is a list, find if in this list we have "SPOT" value or not + if "SPOT" in permission_set: + is_spot = True + break + + return is_trading and is_spot class BinanceConfigMap(BaseConnectorConfigMap): diff --git a/hummingbot/core/rate_oracle/rate_oracle.py b/hummingbot/core/rate_oracle/rate_oracle.py index 2bc58a56ca..ea8291b8cb 100644 --- a/hummingbot/core/rate_oracle/rate_oracle.py +++ b/hummingbot/core/rate_oracle/rate_oracle.py @@ -9,6 +9,7 @@ from hummingbot.core.network_iterator import NetworkStatus from hummingbot.core.rate_oracle.sources.ascend_ex_rate_source import AscendExRateSource from hummingbot.core.rate_oracle.sources.binance_rate_source import BinanceRateSource +from hummingbot.core.rate_oracle.sources.binance_us_rate_source import BinanceUSRateSource from hummingbot.core.rate_oracle.sources.coin_cap_rate_source import CoinCapRateSource from hummingbot.core.rate_oracle.sources.coin_gecko_rate_source import CoinGeckoRateSource from hummingbot.core.rate_oracle.sources.coinbase_advanced_trade_rate_source import CoinbaseAdvancedTradeRateSource @@ -22,6 +23,7 @@ RATE_ORACLE_SOURCES = { "binance": BinanceRateSource, + "binance_us": BinanceUSRateSource, "coin_gecko": CoinGeckoRateSource, "coin_cap": CoinCapRateSource, "kucoin": KucoinRateSource, diff --git a/hummingbot/core/rate_oracle/sources/binance_rate_source.py b/hummingbot/core/rate_oracle/sources/binance_rate_source.py index 5aa65805c4..f3b89ea8c3 100644 --- a/hummingbot/core/rate_oracle/sources/binance_rate_source.py +++ b/hummingbot/core/rate_oracle/sources/binance_rate_source.py @@ -14,7 +14,6 @@ class BinanceRateSource(RateSourceBase): def __init__(self): super().__init__() self._binance_exchange: Optional[BinanceExchange] = None # delayed because of circular reference - self._binance_us_exchange: Optional[BinanceExchange] = None # delayed because of circular reference @property def name(self) -> str: @@ -26,7 +25,6 @@ async def get_prices(self, quote_token: Optional[str] = None) -> Dict[str, Decim results = {} tasks = [ self._get_binance_prices(exchange=self._binance_exchange), - self._get_binance_prices(exchange=self._binance_us_exchange, quote_token="USD"), ] task_results = await safe_gather(*tasks, return_exceptions=True) for task_result in task_results: @@ -43,7 +41,6 @@ async def get_prices(self, quote_token: Optional[str] = None) -> Dict[str, Decim def _ensure_exchanges(self): if self._binance_exchange is None: self._binance_exchange = self._build_binance_connector_without_private_keys(domain="com") - self._binance_us_exchange = self._build_binance_connector_without_private_keys(domain="us") @staticmethod async def _get_binance_prices(exchange: 'BinanceExchange', quote_token: str = None) -> Dict[str, Decimal]: diff --git a/hummingbot/core/rate_oracle/sources/binance_us_rate_source.py b/hummingbot/core/rate_oracle/sources/binance_us_rate_source.py new file mode 100644 index 0000000000..906d119854 --- /dev/null +++ b/hummingbot/core/rate_oracle/sources/binance_us_rate_source.py @@ -0,0 +1,87 @@ +from decimal import Decimal +from typing import TYPE_CHECKING, Dict, Optional + +from hummingbot.connector.utils import split_hb_trading_pair +from hummingbot.core.rate_oracle.sources.rate_source_base import RateSourceBase +from hummingbot.core.utils import async_ttl_cache +from hummingbot.core.utils.async_utils import safe_gather + +if TYPE_CHECKING: + from hummingbot.connector.exchange.binance.binance_exchange import BinanceExchange + + +class BinanceUSRateSource(RateSourceBase): + def __init__(self): + super().__init__() + self._binance_us_exchange: Optional[BinanceExchange] = None # delayed because of circular reference + + @property + def name(self) -> str: + return "binance_us" + + @async_ttl_cache(ttl=30, maxsize=1) + async def get_prices(self, quote_token: Optional[str] = None) -> Dict[str, Decimal]: + self._ensure_exchanges() + results = {} + tasks = [ + self._get_binance_prices(exchange=self._binance_us_exchange, quote_token="USD"), + ] + task_results = await safe_gather(*tasks, return_exceptions=True) + for task_result in task_results: + if isinstance(task_result, Exception): + self.logger().error( + msg="Unexpected error while retrieving rates from Binance. Check the log file for more info.", + exc_info=task_result, + ) + break + else: + results.update(task_result) + return results + + def _ensure_exchanges(self): + if self._binance_us_exchange is None: + self._binance_us_exchange = self._build_binance_connector_without_private_keys(domain="us") + + @staticmethod + async def _get_binance_prices(exchange: 'BinanceExchange', quote_token: str = None) -> Dict[str, Decimal]: + """ + Fetches binance prices + + :param exchange: The exchange instance from which to query prices. + :param quote_token: A quote symbol, if specified only pairs with the quote symbol are included for prices + :return: A dictionary of trading pairs and prices + """ + pairs_prices = await exchange.get_all_pairs_prices() + results = {} + for pair_price in pairs_prices: + try: + trading_pair = await exchange.trading_pair_associated_to_exchange_symbol(symbol=pair_price["symbol"]) + except KeyError: + continue # skip pairs that we don't track + if quote_token is not None: + base, quote = split_hb_trading_pair(trading_pair=trading_pair) + if quote != quote_token: + continue + bid_price = pair_price.get("bidPrice") + ask_price = pair_price.get("askPrice") + if bid_price is not None and ask_price is not None and 0 < Decimal(bid_price) <= Decimal(ask_price): + results[trading_pair] = (Decimal(bid_price) + Decimal(ask_price)) / Decimal("2") + + return results + + @staticmethod + def _build_binance_connector_without_private_keys(domain: str) -> 'BinanceExchange': + from hummingbot.client.hummingbot_application import HummingbotApplication + from hummingbot.connector.exchange.binance.binance_exchange import BinanceExchange + + app = HummingbotApplication.main_application() + client_config_map = app.client_config_map + + return BinanceExchange( + client_config_map=client_config_map, + binance_api_key="", + binance_api_secret="", + trading_pairs=[], + trading_required=False, + domain=domain, + ) diff --git a/test/hummingbot/connector/exchange/binance/test_binance_exchange.py b/test/hummingbot/connector/exchange/binance/test_binance_exchange.py index 37fd9b979b..3d713bcf68 100644 --- a/test/hummingbot/connector/exchange/binance/test_binance_exchange.py +++ b/test/hummingbot/connector/exchange/binance/test_binance_exchange.py @@ -84,10 +84,10 @@ def all_symbols_request_mock_response(self): "isSpotTradingAllowed": True, "isMarginTradingAllowed": True, "filters": [], - "permissions": [ + "permissionSets": [[ "SPOT", "MARGIN" - ] + ]] }, ] } @@ -149,9 +149,9 @@ def all_symbols_including_invalid_pair_mock_response(self) -> Tuple[str, Any]: "isSpotTradingAllowed": True, "isMarginTradingAllowed": True, "filters": [], - "permissions": [ + "permissionSets": [[ "MARGIN" - ] + ]] }, { "symbol": self.exchange_symbol_for_tokens("INVALID", "PAIR"), @@ -176,9 +176,9 @@ def all_symbols_including_invalid_pair_mock_response(self) -> Tuple[str, Any]: "isSpotTradingAllowed": True, "isMarginTradingAllowed": True, "filters": [], - "permissions": [ + "permissionSets": [[ "MARGIN" - ] + ]] }, ] } @@ -226,10 +226,10 @@ def trading_rules_request_mock_response(self): "minNotional": "0.00100000" } ], - "permissions": [ + "permissionSets": [[ "SPOT", "MARGIN" - ] + ]] } ] } @@ -255,10 +255,10 @@ def trading_rules_request_erroneous_mock_response(self): "ocoAllowed": True, "isSpotTradingAllowed": True, "isMarginTradingAllowed": True, - "permissions": [ + "permissionSets": [[ "SPOT", "MARGIN" - ] + ]] } ] } @@ -297,9 +297,9 @@ def balance_request_mock_response_for_base_and_quote(self): "locked": "0.00000000" } ], - "permissions": [ + "permissionSets": [[ "SPOT" - ] + ]] } @property @@ -315,7 +315,7 @@ def balance_request_mock_response_only_base(self): "updateTime": 123456789, "accountType": "SPOT", "balances": [{"asset": self.base_asset, "free": "10.0", "locked": "5.0"}], - "permissions": ["SPOT"], + "permissionSets": [["SPOT"]], } @property @@ -1180,9 +1180,9 @@ def test_format_trading_rules__min_notional_present(self): "minNotional": "0.00100000" } ], - "permissions": [ + "permissionSets": [[ "SPOT" - ] + ]] }] exchange_info = {"symbols": trading_rules} @@ -1217,9 +1217,9 @@ def test_format_trading_rules__notional_but_no_min_notional_present(self): "avgPriceMins": 5 } ], - "permissions": [ + "permissionSets": [[ "SPOT" - ] + ]] }] exchange_info = {"symbols": trading_rules} diff --git a/test/hummingbot/connector/exchange/binance/test_binance_utils.py b/test/hummingbot/connector/exchange/binance/test_binance_utils.py index 40c9666bd5..4ab8c14fa5 100644 --- a/test/hummingbot/connector/exchange/binance/test_binance_utils.py +++ b/test/hummingbot/connector/exchange/binance/test_binance_utils.py @@ -17,28 +17,28 @@ def setUpClass(cls) -> None: def test_is_exchange_information_valid(self): invalid_info_1 = { "status": "BREAK", - "permissions": ["MARGIN"], + "permissionSets": [["MARGIN"]], } self.assertFalse(utils.is_exchange_information_valid(invalid_info_1)) invalid_info_2 = { "status": "BREAK", - "permissions": ["SPOT"], + "permissionSets": [["SPOT"]], } self.assertFalse(utils.is_exchange_information_valid(invalid_info_2)) invalid_info_3 = { "status": "TRADING", - "permissions": ["MARGIN"], + "permissionSets": [["MARGIN"]], } self.assertFalse(utils.is_exchange_information_valid(invalid_info_3)) invalid_info_4 = { "status": "TRADING", - "permissions": ["SPOT"], + "permissionSets": [["SPOT"]], } self.assertTrue(utils.is_exchange_information_valid(invalid_info_4)) diff --git a/test/hummingbot/core/rate_oracle/sources/test_binance_rate_source.py b/test/hummingbot/core/rate_oracle/sources/test_binance_rate_source.py index 091816d1b2..e864a018bf 100644 --- a/test/hummingbot/core/rate_oracle/sources/test_binance_rate_source.py +++ b/test/hummingbot/core/rate_oracle/sources/test_binance_rate_source.py @@ -18,10 +18,8 @@ def setUpClass(cls): cls.ev_loop = asyncio.get_event_loop() cls.target_token = "COINALPHA" cls.global_token = "HBOT" - cls.binance_pair = f"{cls.target_token}{cls.global_token}" + cls.binance_pair = f"{cls.target_token}-{cls.global_token}" cls.trading_pair = combine_to_hb_trading_pair(base=cls.target_token, quote=cls.global_token) - cls.binance_us_pair = f"{cls.target_token}USD" - cls.us_trading_pair = combine_to_hb_trading_pair(base=cls.target_token, quote="USD") cls.binance_ignored_pair = "SOMEPAIR" cls.ignored_trading_pair = combine_to_hb_trading_pair(base="SOME", quote="PAIR") @@ -30,7 +28,6 @@ def async_run_with_timeout(self, coroutine: Awaitable, timeout: int = 1): return ret def setup_binance_responses(self, mock_api, expected_rate: Decimal): - pairs_us_url = web_utils.public_rest_url(path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL, domain="us") pairs_url = web_utils.public_rest_url(path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL) symbols_response = { # truncated "symbols": [ @@ -39,47 +36,22 @@ def setup_binance_responses(self, mock_api, expected_rate: Decimal): "status": "TRADING", "baseAsset": self.target_token, "quoteAsset": self.global_token, - "permissions": [ + "permissionSets": [[ "SPOT", - ], - }, - { - "symbol": self.binance_us_pair, - "status": "TRADING", - "baseAsset": self.target_token, - "quoteAsset": "USD", - "permissions": [ - "SPOT", - ], + ]], }, { "symbol": self.binance_ignored_pair, "status": "PAUSED", "baseAsset": "SOME", "quoteAsset": "PAIR", - "permissions": [ + "permissionSets": [[ "SPOT", - ], + ]], }, ] } - binance_prices_us_url = web_utils.public_rest_url(path_url=CONSTANTS.TICKER_BOOK_PATH_URL, domain="us") - binance_prices_us_response = [ - { - "symbol": self.binance_us_pair, - "bidPrice": "20862.0000", - "bidQty": "0.50000000", - "askPrice": "20865.6100", - "askQty": "0.14500000", - }, - { - "symbol": self.binance_ignored_pair, - "bidPrice": "0", - "bidQty": "0", - "askPrice": "0", - "askQty": "0", - } - ] + binance_prices_global_url = web_utils.public_rest_url(path_url=CONSTANTS.TICKER_BOOK_PATH_URL) binance_prices_global_response = [ { @@ -90,9 +62,9 @@ def setup_binance_responses(self, mock_api, expected_rate: Decimal): "askQty": "0.14500000", } ] - mock_api.get(pairs_us_url, body=json.dumps(symbols_response)) + # mock_api.get(pairs_us_url, body=json.dumps(symbols_response)) mock_api.get(pairs_url, body=json.dumps(symbols_response)) - mock_api.get(binance_prices_us_url, body=json.dumps(binance_prices_us_response)) + # mock_api.get(binance_prices_us_url, body=json.dumps(binance_prices_us_response)) mock_api.get(binance_prices_global_url, body=json.dumps(binance_prices_global_response)) @aioresponses() @@ -105,5 +77,5 @@ def test_get_binance_prices(self, mock_api): self.assertIn(self.trading_pair, prices) self.assertEqual(expected_rate, prices[self.trading_pair]) - self.assertIn(self.us_trading_pair, prices) + # self.assertIn(self.us_trading_pair, prices) self.assertNotIn(self.ignored_trading_pair, prices) diff --git a/test/hummingbot/core/rate_oracle/sources/test_binance_us_rate_source.py b/test/hummingbot/core/rate_oracle/sources/test_binance_us_rate_source.py new file mode 100644 index 0000000000..cfc1232917 --- /dev/null +++ b/test/hummingbot/core/rate_oracle/sources/test_binance_us_rate_source.py @@ -0,0 +1,83 @@ +import asyncio +import json +import unittest +from decimal import Decimal +from typing import Awaitable + +from aioresponses import aioresponses + +from hummingbot.connector.exchange.binance import binance_constants as CONSTANTS, binance_web_utils as web_utils +from hummingbot.connector.utils import combine_to_hb_trading_pair +from hummingbot.core.rate_oracle.sources.binance_us_rate_source import BinanceUSRateSource + + +class BinanceUSRateSourceTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.ev_loop = asyncio.get_event_loop() + cls.target_token = "COINALPHA" + cls.binance_us_pair = f"{cls.target_token}USD" + cls.us_trading_pair = combine_to_hb_trading_pair(base=cls.target_token, quote="USD") + cls.binance_ignored_pair = "SOMEPAIR" + cls.ignored_trading_pair = combine_to_hb_trading_pair(base="SOME", quote="PAIR") + + def async_run_with_timeout(self, coroutine: Awaitable, timeout: int = 1): + ret = asyncio.get_event_loop().run_until_complete(asyncio.wait_for(coroutine, timeout)) + return ret + + def setup_binance_us_responses(self, mock_api, expected_rate: Decimal): + pairs_us_url = web_utils.public_rest_url(path_url=CONSTANTS.EXCHANGE_INFO_PATH_URL, domain="us") + symbols_response = { # truncated + "symbols": [ + { + "symbol": self.binance_us_pair, + "status": "TRADING", + "baseAsset": self.target_token, + "quoteAsset": "USD", + "permissionSets": [[ + "SPOT", + ]], + }, + { + "symbol": self.binance_ignored_pair, + "status": "PAUSED", + "baseAsset": "SOME", + "quoteAsset": "PAIR", + "permissionSets": [[ + "SPOT", + ]], + }, + ] + } + binance_prices_us_url = web_utils.public_rest_url(path_url=CONSTANTS.TICKER_BOOK_PATH_URL, domain="us") + binance_prices_us_response = [ + { + "symbol": self.binance_us_pair, + "bidPrice": str(expected_rate - Decimal("0.1")), + "bidQty": "0.50000000", + "askPrice": str(expected_rate + Decimal("0.1")), + "askQty": "0.14500000", + }, + { + "symbol": self.binance_ignored_pair, + "bidPrice": "0", + "bidQty": "0", + "askPrice": "0", + "askQty": "0", + } + ] + mock_api.get(pairs_us_url, body=json.dumps(symbols_response)) + mock_api.get(binance_prices_us_url, body=json.dumps(binance_prices_us_response)) + + @aioresponses() + def test_get_binance_prices(self, mock_api): + expected_rate = Decimal("10") + self.setup_binance_us_responses(mock_api=mock_api, expected_rate=expected_rate) + + rate_source = BinanceUSRateSource() + prices = self.async_run_with_timeout(rate_source.get_prices()) + + self.assertIn(self.us_trading_pair, prices) + self.assertEqual(expected_rate, prices[self.us_trading_pair]) + self.assertNotIn(self.ignored_trading_pair, prices) diff --git a/test/hummingbot/core/utils/test_trading_pair_fetcher.py b/test/hummingbot/core/utils/test_trading_pair_fetcher.py index 3436cf33e6..16253c8544 100644 --- a/test/hummingbot/core/utils/test_trading_pair_fetcher.py +++ b/test/hummingbot/core/utils/test_trading_pair_fetcher.py @@ -203,10 +203,10 @@ def test_fetch_all(self, mock_api, con_spec_mock, perp_market_mock, all_connecto "isSpotTradingAllowed": True, "isMarginTradingAllowed": True, "filters": [], - "permissions": [ + "permissionSets": [[ "SPOT", "MARGIN" - ] + ]] }, { "symbol": "LTCBTC", @@ -231,10 +231,10 @@ def test_fetch_all(self, mock_api, con_spec_mock, perp_market_mock, all_connecto "isSpotTradingAllowed": True, "isMarginTradingAllowed": True, "filters": [], - "permissions": [ + "permissionSets": [[ "SPOT", "MARGIN" - ] + ]] }, { "symbol": "BNBBTC", @@ -259,9 +259,9 @@ def test_fetch_all(self, mock_api, con_spec_mock, perp_market_mock, all_connecto "isSpotTradingAllowed": True, "isMarginTradingAllowed": True, "filters": [], - "permissions": [ + "permissionSets": [[ "MARGIN" - ] + ]] }, ] }