diff --git a/docs/exchanges.md b/docs/exchanges.md index 358242fe931..55ca8a5566f 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -428,6 +428,36 @@ Your balance and trades will now be used from your vault / subaccount - and no l The Hyperliquid API does not provide historic data beyond the single call to fetch current data, so downloading data is not possible, as the downloaded data would not constitute proper historic data. +### HIP-3 DEXes + +Hyperliquid supports HIP-3 decentralized exchanges (DEXes), which are independent exchanges built on top of the Hyperliquid infrastructure. +These DEXes operate similarly to the main Hyperliquid exchange but are community-created and managed. + +To trade on HIP-3 DEXes with Freqtrade, you need to add them to your configuration using the `hip3_dexes` parameter: + +```json +"exchange": { + "name": "hyperliquid", + "walletAddress": "your_master_wallet_address", + "privateKey": "your_api_private_key", + "hip3_dexes": ["dex_name_1", "dex_name_2"] +} +``` + +Replace `"dex_name_1"` and `"dex_name_2"` with the actual names of the HIP-3 DEXes you want to trade on (e.g. `vntl` and `xyz`). + +!!! Warning "Performance and Rate Limit Impact" + Each HIP-3 DEX you add significantly impacts bot performance and rate limits. + + * **Additional API Calls**: For each HIP-3 DEX configured, Freqtrade needs to make additional API calls. + * **Rate Limit Pressure**: Additional API calls contribute to Hyperliquid's strict rate limits. With multiple DEXes, you may hit rate limits faster, or rather, slow down bot operations due to enforced delays. + + Please only add HIP-3 DEXes that you actively trade on. Monitor your logs for rate limit warnings or signs of slowed operations, and adjust your configuration accordingly. + Different HIP-3 DEXes may also use different quote currencies - so make sure to only add DEXes that are compatible with your stake currency to avoid unnecessary delays. + +!!! Note + HIP-3 DEXes share the same wallet and free amount of collateral as your main Hyperliquid account. Trades on different DEXes will affect your overall account balance and margin. + ## Bitvavo If your account is required to use an operatorId, you can set it in the configuration file as follows: diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 605d6721a4f..07b966a568a 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -1827,16 +1827,16 @@ def cancel_stoploss_order_with_result( return order @retrier - def get_balances(self) -> CcxtBalances: + def get_balances(self, params: dict | None = None) -> CcxtBalances: try: - balances = self._api.fetch_balance() + balances = self._api.fetch_balance(params or {}) # Remove additional info from ccxt results balances.pop("info", None) balances.pop("free", None) balances.pop("total", None) balances.pop("used", None) - self._log_exchange_response("fetch_balance", balances) + self._log_exchange_response("fetch_balance", balances, add_info=params) return balances except ccxt.DDoSProtection as e: raise DDosProtection(e) from e @@ -1848,7 +1848,9 @@ def get_balances(self) -> CcxtBalances: raise OperationalException(e) from e @retrier - def fetch_positions(self, pair: str | None = None) -> list[CcxtPosition]: + def fetch_positions( + self, pair: str | None = None, params: dict | None = None + ) -> list[CcxtPosition]: """ Fetch positions from the exchange. If no pair is given, all positions are returned. @@ -1860,7 +1862,7 @@ def fetch_positions(self, pair: str | None = None) -> list[CcxtPosition]: symbols = None if pair: symbols = [pair] - positions: list[CcxtPosition] = self._api.fetch_positions(symbols) + positions: list[CcxtPosition] = self._api.fetch_positions(symbols, params=params or {}) self._log_exchange_response("fetch_positions", positions) return positions except ccxt.DDoSProtection as e: diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index f5e6fb7eef5..4a9e1547be9 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -7,9 +7,10 @@ from freqtrade.constants import BuySell from freqtrade.enums import MarginMode, TradingMode -from freqtrade.exceptions import ExchangeError, OperationalException +from freqtrade.enums.runmode import NON_UTIL_MODES +from freqtrade.exceptions import ConfigurationError, ExchangeError, OperationalException from freqtrade.exchange import Exchange -from freqtrade.exchange.exchange_types import CcxtOrder, FtHas +from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, CcxtPosition, FtHas from freqtrade.util.datetime_helpers import dt_from_ts @@ -57,12 +58,108 @@ def _ccxt_config(self) -> dict: config.update(super()._ccxt_config) return config + def _get_configured_hip3_dexes(self) -> list[str]: + """Get list of configured HIP-3 DEXes.""" + return self._config.get("exchange", {}).get("hip3_dexes", []) + + def validate_config(self, config: dict) -> None: + """Validate HIP-3 configuration at bot startup.""" + super().validate_config(config) + configured = self._get_configured_hip3_dexes() + if not configured or not self.markets: + return + if self.trading_mode != TradingMode.FUTURES: + if configured: + raise ConfigurationError( + "HIP-3 DEXes are only supported in FUTURES trading mode. " + "Please update your configuration!" + ) + return + if configured and self.margin_mode != MarginMode.ISOLATED: + raise ConfigurationError( + "HIP-3 DEXes require 'isolated' margin mode. " + f"Current margin mode: '{self.margin_mode.value}'. " + "Please update your configuration!" + ) + + available = { + m.get("info", {}).get("dex") + for m in self.get_markets( + quote_currencies=[self._config["stake_currency"]], + tradable_only=True, + active_only=True, + ).values() + if m.get("info", {}).get("hip3") + } + available.discard(None) + + invalid = set(configured) - available + if invalid: + raise ConfigurationError( + f"Invalid HIP-3 DEXes configured: {sorted(invalid)}. " + f"Available DEXes matching your stake currency ({self._config['stake_currency']}): " + f"{sorted(available)}. " + f"Check your 'hip3_dexes' configuration!" + ) + def market_is_tradable(self, market: dict[str, Any]) -> bool: + """Check if market is tradable, including HIP-3 markets.""" parent_check = super().market_is_tradable(market) - # Exclude hip3 markets for now - which have the format XYZ:GOOGL/USDT:USDT - - # and XYZ:GOOGL as base - return parent_check and ":" not in market["base"] + market_info = market.get("info", {}) + if market_info.get("hip3") and self._config["runmode"] in NON_UTIL_MODES: + configured = self._get_configured_hip3_dexes() + if not configured: + return False + + market_dex = market_info.get("dex") + return parent_check and market_dex in configured + + return parent_check + + def get_balances(self, params: dict | None = None) -> CcxtBalances: + """Fetch balances from default DEX and HIP-3 DEXes needed by tradable pairs. + This override is not absolutely necessary and is only there for correct used / total values + which are however not used by Freqtrade in futures mode at the moment. + """ + balances = super().get_balances() + dexes = self._get_configured_hip3_dexes() + for dex in dexes: + try: + dex_balance = super().get_balances(params={"dex": dex}) + + for currency, amount_info in dex_balance.items(): + if currency in ["info", "free", "used", "total", "datetime", "timestamp"]: + continue + + if currency not in balances: + balances[currency] = amount_info + else: + balances[currency]["free"] += amount_info["free"] + balances[currency]["used"] += amount_info["used"] + balances[currency]["total"] += amount_info["total"] + + except Exception as e: + logger.error(f"Could not fetch balance for HIP-3 DEX '{dex}': {e}") + + if dexes: + self._log_exchange_response("fetch_balance", balances, add_info="combined") + return balances + + def fetch_positions( + self, pair: str | None = None, params: dict | None = None + ) -> list[CcxtPosition]: + """Fetch positions from default DEX and HIP-3 DEXes needed by tradable pairs.""" + positions = super().fetch_positions(pair) + dexes = self._get_configured_hip3_dexes() + for dex in dexes: + try: + positions.extend(super().fetch_positions(pair, params={"dex": dex})) + except Exception as e: + logger.error(f"Could not fetch positions from HIP-3 DEX '{dex}': {e}") + if dexes: + self._log_exchange_response("fetch_positions", positions, add_info="combined") + return positions def get_max_leverage(self, pair: str, stake_amount: float | None) -> float: # There are no leverage tiers diff --git a/freqtrade/exchange/kraken.py b/freqtrade/exchange/kraken.py index 60efdf95487..949f0253026 100644 --- a/freqtrade/exchange/kraken.py +++ b/freqtrade/exchange/kraken.py @@ -70,7 +70,7 @@ def consolidate_balances(self, balances: CcxtBalances) -> CcxtBalances: return consolidated @retrier - def get_balances(self) -> CcxtBalances: + def get_balances(self, params: dict | None = None) -> CcxtBalances: if self._config["dry_run"]: return {} diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index c2bff933900..70a38f04cab 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -1,21 +1,125 @@ +from copy import deepcopy from datetime import UTC, datetime from unittest.mock import MagicMock, PropertyMock import pytest -from tests.conftest import EXMS, get_mock_coro, get_patched_exchange +from freqtrade.exceptions import ConfigurationError +from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has_re + + +@pytest.fixture +def markets_hip3(): + markets = { + "BTC/USDC:USDC": { + "quote": "USDC", + "base": "BTC", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 50}}, + "info": {}, + }, + "ETH/USDC:USDC": { + "quote": "USDC", + "base": "ETH", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 50}}, + "info": {}, + }, + "SOL/USDC:USDC": { + "quote": "USDC", + "base": "SOL", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 20}}, + "info": {}, + }, + "DOGE/USDC:USDC": { + "quote": "USDC", + "base": "DOGE", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 20}}, + "info": {}, + }, + "XYZ-AAPL/USDC:USDC": { + "quote": "USDC", + "base": "XYZ-AAPL", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 10}}, + "info": {"hip3": True, "dex": "xyz"}, + }, + "XYZ-TSLA/USDC:USDC": { + "quote": "USDC", + "base": "XYZ-TSLA", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 10}}, + "info": {"hip3": True, "dex": "xyz"}, + }, + "XYZ-GOOGL/USDC:USDC": { + "quote": "USDC", + "base": "XYZ-GOOGL", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 10}}, + "info": {"hip3": True, "dex": "xyz"}, + }, + "XYZ-NVDA/USDC:USDC": { + "quote": "USDC", + "base": "XYZ-NVDA", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 10}}, + "info": {"hip3": True, "dex": "xyz"}, + }, + "VNTL-SPACEX/USDH:USDH": { + "quote": "USDH", + "base": "VNTL-SPACEX", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 3}}, + "info": {"hip3": True, "dex": "vntl"}, + }, + "VNTL-ANTHROPIC/USDH:USDH": { + "quote": "USDH", + "base": "VNTL-ANTHROPIC", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 3}}, + "info": {"hip3": True, "dex": "vntl"}, + }, + "FLX-TOKEN/USDC:USDC": { + "quote": "USDC", + "base": "FLX-TOKEN", + "type": "swap", + "swap": True, + "linear": True, + "limits": {"leverage": {"max": 3}}, + "info": {"hip3": True, "dex": "flx"}, + }, + } + + return markets @pytest.mark.parametrize("margin_mode", ["isolated", "cross"]) -def test_hyperliquid_dry_run_liquidation_price(default_conf, mocker, margin_mode): +def test_hyperliquid_dry_run_liquidation_price(default_conf, markets_hip3, mocker, margin_mode): # test if liq price calculated by dry_run_liquidation_price() is close to ccxt liq price # testing different pairs with large/small prices, different leverages, long, short - markets = { - "BTC/USDC:USDC": {"limits": {"leverage": {"max": 50}}}, - "ETH/USDC:USDC": {"limits": {"leverage": {"max": 50}}}, - "SOL/USDC:USDC": {"limits": {"leverage": {"max": 20}}}, - "DOGE/USDC:USDC": {"limits": {"leverage": {"max": 20}}}, - } + positions = [ { "symbol": "ETH/USDC:USDC", @@ -278,6 +382,33 @@ def test_hyperliquid_dry_run_liquidation_price(default_conf, mocker, margin_mode "leverage": 3.0, "liquidationPrice": 45236.52992613, }, + { + "symbol": "XYZ-AAPL/USDC:USDC", + "entryPrice": 250.0, + "side": "long", + "contracts": 0.5, + "collateral": 25.0, + "leverage": 5.0, + "liquidationPrice": 210.5263157894737, + }, + { + "symbol": "XYZ-GOOGL/USDC:USDC", + "entryPrice": 190.0, + "side": "short", + "contracts": 0.5, + "collateral": 9.5, + "leverage": 10.0, + "liquidationPrice": 199.04761904761904, + }, + { + "symbol": "XYZ-TSLA/USDC:USDC", + "entryPrice": 350.0, + "side": "long", + "contracts": 1.0, + "collateral": 50.0, + "leverage": 7.0, + "liquidationPrice": 315.7894736842105, + }, ] api_mock = MagicMock() @@ -285,7 +416,7 @@ def test_hyperliquid_dry_run_liquidation_price(default_conf, mocker, margin_mode default_conf["margin_mode"] = margin_mode default_conf["stake_currency"] = "USDC" api_mock.load_markets = get_mock_coro() - api_mock.markets = markets + api_mock.markets = markets_hip3 exchange = get_patched_exchange( mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False ) @@ -331,41 +462,62 @@ def test_hyperliquid_get_funding_fees(default_conf, mocker): now = datetime.now(UTC) exchange = get_patched_exchange(mocker, default_conf, exchange="hyperliquid") exchange._fetch_and_calculate_funding_fees = MagicMock() + + # Spot mode - no funding fees exchange.get_funding_fees("BTC/USDC:USDC", 1, False, now) assert exchange._fetch_and_calculate_funding_fees.call_count == 0 default_conf["trading_mode"] = "futures" default_conf["margin_mode"] = "isolated" + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl"] + + # Mock validate_config to skip validation + mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config") + exchange = get_patched_exchange(mocker, default_conf, exchange="hyperliquid") exchange._fetch_and_calculate_funding_fees = MagicMock() + + # Normal market exchange.get_funding_fees("BTC/USDC:USDC", 1, False, now) + assert exchange._fetch_and_calculate_funding_fees.call_count == 1 + + # HIP-3 XYZ market + exchange._fetch_and_calculate_funding_fees.reset_mock() + exchange.get_funding_fees("XYZ-TSLA/USDC:USDC", 1, False, now) + assert exchange._fetch_and_calculate_funding_fees.call_count == 1 + # HIP-3 VNTL market + exchange._fetch_and_calculate_funding_fees.reset_mock() + exchange.get_funding_fees("VNTL-SPACEX/USDH:USDH", 1, True, now) assert exchange._fetch_and_calculate_funding_fees.call_count == 1 -def test_hyperliquid_get_max_leverage(default_conf, mocker): - markets = { - "BTC/USDC:USDC": {"limits": {"leverage": {"max": 50}}}, - "ETH/USDC:USDC": {"limits": {"leverage": {"max": 50}}}, - "SOL/USDC:USDC": {"limits": {"leverage": {"max": 20}}}, - "DOGE/USDC:USDC": {"limits": {"leverage": {"max": 20}}}, - } +def test_hyperliquid_get_max_leverage(default_conf, mocker, markets_hip3): exchange = get_patched_exchange(mocker, default_conf, exchange="hyperliquid") assert exchange.get_max_leverage("BTC/USDC:USDC", 1) == 1.0 default_conf["trading_mode"] = "futures" default_conf["margin_mode"] = "isolated" + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl"] + + # Mock validate_config to skip validation + mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config") + exchange = get_patched_exchange(mocker, default_conf, exchange="hyperliquid") - mocker.patch.multiple( - EXMS, - markets=PropertyMock(return_value=markets), - ) + mocker.patch.multiple(EXMS, markets=PropertyMock(return_value=markets_hip3)) + # Normal markets assert exchange.get_max_leverage("BTC/USDC:USDC", 1) == 50 assert exchange.get_max_leverage("ETH/USDC:USDC", 20) == 50 assert exchange.get_max_leverage("SOL/USDC:USDC", 50) == 20 assert exchange.get_max_leverage("DOGE/USDC:USDC", 3) == 20 + # HIP-3 markets + assert exchange.get_max_leverage("XYZ-TSLA/USDC:USDC", 1) == 10 + assert exchange.get_max_leverage("XYZ-NVDA/USDC:USDC", 5) == 10 + assert exchange.get_max_leverage("VNTL-SPACEX/USDH:USDH", 2) == 3 + assert exchange.get_max_leverage("VNTL-ANTHROPIC/USDH:USDH", 1) == 3 + def test_hyperliquid__lev_prep(default_conf, mocker): api_mock = MagicMock() @@ -382,25 +534,59 @@ def test_hyperliquid__lev_prep(default_conf, mocker): default_conf["trading_mode"] = "futures" default_conf["margin_mode"] = "isolated" + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl"] + + # Mock validate_config to skip validation + mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config") exchange = get_patched_exchange(mocker, default_conf, api_mock, exchange="hyperliquid") - exchange._lev_prep("BTC/USDC:USDC", 3.2, "buy") + # Normal market + exchange._lev_prep("BTC/USDC:USDC", 3.2, "buy") assert api_mock.set_margin_mode.call_count == 1 api_mock.set_margin_mode.assert_called_with("isolated", "BTC/USDC:USDC", {"leverage": 3}) api_mock.reset_mock() - exchange._lev_prep("BTC/USDC:USDC", 19.99, "sell") - assert api_mock.set_margin_mode.call_count == 1 api_mock.set_margin_mode.assert_called_with("isolated", "BTC/USDC:USDC", {"leverage": 19}) + # HIP-3 XYZ market + api_mock.reset_mock() + exchange._lev_prep("XYZ-TSLA/USDC:USDC", 5.7, "buy") + assert api_mock.set_margin_mode.call_count == 1 + api_mock.set_margin_mode.assert_called_with("isolated", "XYZ-TSLA/USDC:USDC", {"leverage": 5}) + + api_mock.reset_mock() + exchange._lev_prep("XYZ-TSLA/USDC:USDC", 10.0, "sell") + assert api_mock.set_margin_mode.call_count == 1 + api_mock.set_margin_mode.assert_called_with("isolated", "XYZ-TSLA/USDC:USDC", {"leverage": 10}) + + # HIP-3 VNTL market + api_mock.reset_mock() + exchange._lev_prep("VNTL-SPACEX/USDH:USDH", 2.5, "buy") + assert api_mock.set_margin_mode.call_count == 1 + api_mock.set_margin_mode.assert_called_with( + "isolated", "VNTL-SPACEX/USDH:USDH", {"leverage": 2} + ) + + api_mock.reset_mock() + exchange._lev_prep("VNTL-ANTHROPIC/USDH:USDH", 3.0, "sell") + assert api_mock.set_margin_mode.call_count == 1 + api_mock.set_margin_mode.assert_called_with( + "isolated", "VNTL-ANTHROPIC/USDH:USDH", {"leverage": 3} + ) + -def test_hyperliquid_fetch_order(default_conf_usdt, mocker): +def test_hyperliquid_fetch_order(default_conf_usdt, mocker, markets_hip3): default_conf_usdt["dry_run"] = False + default_conf_usdt["trading_mode"] = "futures" + default_conf_usdt["margin_mode"] = "isolated" + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz", "vntl"] api_mock = MagicMock() + + # Test with normal market api_mock.fetch_order = MagicMock( return_value={ "id": "12345", @@ -432,9 +618,293 @@ def test_hyperliquid_fetch_order(default_conf_usdt, mocker): }, ], ) - exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange="hyperliquid") + mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config") + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) o = exchange.fetch_order("12345", "ETH/USDC:USDC") # Uses weighted average assert o["average"] == 1500 + assert gtfo_mock.call_count == 1 + + # Test with HIP-3 XYZ market + api_mock.fetch_order = MagicMock( + return_value={ + "id": "67890", + "symbol": "XYZ-TSLA/USDC:USDC", + "status": "closed", + "filled": 2.5, + "average": None, + "timestamp": 1630000100, + } + ) + gtfo_mock.reset_mock() + gtfo_mock.return_value = [ + { + "order_id": "67890", + "price": 250, + "amount": 1.5, + "filled": 1.5, + "remaining": 0, + }, + { + "order_id": "67890", + "price": 260, + "amount": 1.0, + "filled": 1.0, + "remaining": 0, + }, + ] + + o = exchange.fetch_order("67890", "XYZ-TSLA/USDC:USDC") + # Weighted average: (250*1.5 + 260*1.0) / 2.5 = 254 + assert o["average"] == 254 + assert gtfo_mock.call_count == 1 + + # Test with HIP-3 VNTL market + api_mock.fetch_order = MagicMock( + return_value={ + "id": "11111", + "symbol": "VNTL-SPACEX/USDH:USDH", + "status": "closed", + "filled": 5.0, + "average": None, + "timestamp": 1630000200, + } + ) + gtfo_mock.reset_mock() + gtfo_mock.return_value = [ + { + "order_id": "11111", + "price": 100, + "amount": 3.0, + "filled": 3.0, + "remaining": 0, + }, + { + "order_id": "11111", + "price": 105, + "amount": 2.0, + "filled": 2.0, + "remaining": 0, + }, + ] + o = exchange.fetch_order("11111", "VNTL-SPACEX/USDH:USDH") + assert o["average"] == 102 assert gtfo_mock.call_count == 1 + + +def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker, markets_hip3): + """Test HIP-3 DEX configuration validation.""" + + api_mock = MagicMock() + default_conf_usdt["stake_currency"] = "USDC" + + # Futures mode, no dex configured + default_conf_copy = deepcopy(default_conf_usdt) + default_conf_copy["trading_mode"] = "futures" + default_conf_copy["margin_mode"] = "isolated" + exchange = get_patched_exchange( + mocker, default_conf_copy, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) + exchange.validate_config(default_conf_copy) + + # Not in futures mode - no dex configured - no error + get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) + # Not in futures mode + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"] + with pytest.raises( + ConfigurationError, match=r"HIP-3 DEXes are only supported in FUTURES trading mode\." + ): + get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) + # Valid single DEX + default_conf_usdt["trading_mode"] = "futures" + default_conf_usdt["margin_mode"] = "isolated" + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"] + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) + assert exchange._get_configured_hip3_dexes() == ["xyz"] + + # Invalid DEX + default_conf_usdt["exchange"]["hip3_dexes"] = ["invalid_dex"] + with pytest.raises(ConfigurationError, match="Invalid HIP-3 DEXes configured"): + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) + exchange.validate_config(default_conf_usdt) + + # Mix of valid and invalid DEX + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz", "invalid_dex"] + with pytest.raises(ConfigurationError, match="Invalid HIP-3 DEXes configured"): + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) + exchange.validate_config(default_conf_usdt) + + default_conf_usdt["margin_mode"] = "cross" + with pytest.raises(ConfigurationError, match="HIP-3 DEXes require 'isolated' margin mode"): + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) + exchange.validate_config(default_conf_usdt) + + +def test_hyperliquid_get_balances_hip3(default_conf, mocker, caplog, markets_hip3): + """Test balance fetching from HIP-3 DEXes.""" + api_mock = MagicMock() + + api_mock.load_markets = get_mock_coro() + + # Mock balance responses + default_balance = {"USDC": {"free": 1000, "used": 0, "total": 1000}} + xyz_balance = {"USDC": {"free": 0, "used": 600, "total": 600}} + vntl_balance = {"USDH": {"free": 0, "used": 300, "total": 300}} + + def fetch_balance_side_effect(params=None): + if params and params.get("dex") == "xyz": + return xyz_balance + elif params and params.get("dex") == "vntl": + return vntl_balance + elif params and params.get("dex") == "flx": + raise Exception("FLX DEX error") + return default_balance + + api_mock.fetch_balance = MagicMock(side_effect=fetch_balance_side_effect) + + # Test with two HIP-3 DEXes + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl", "flx"] + default_conf["trading_mode"] = "futures" + default_conf["margin_mode"] = "isolated" + mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config") + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) + + balances = exchange.get_balances() + + # Should have combined balances + assert balances["USDC"]["free"] == 1000 + assert balances["USDC"]["used"] == 600 + assert balances["USDC"]["total"] == 1600 + assert balances["USDH"]["free"] == 0 + assert balances["USDH"]["used"] == 300 + assert balances["USDH"]["total"] == 300 + + assert api_mock.fetch_balance.call_count == 4 + assert log_has_re("Could not fetch balance for HIP-3 DEX.*", caplog) + + +def test_hyperliquid_fetch_positions_hip3(default_conf, mocker, caplog, markets_hip3): + """Test position fetching from HIP-3 DEXes.""" + api_mock = MagicMock() + + # Mock position responses + default_positions = [{"symbol": "BTC/USDC:USDC", "contracts": 0.5}] + xyz_positions = [{"symbol": "XYZ-AAPL/USDC:USDC", "contracts": 10}] + vntl_positions = [{"symbol": "VNTL-SPACEX/USDH:USDH", "contracts": 5}] + + def fetch_positions_side_effect(symbols=None, params=None): + if params and params.get("dex") == "xyz": + return xyz_positions + elif params and params.get("dex") == "vntl": + return vntl_positions + elif params and params.get("dex") == "flx": + raise Exception("FLX DEX error") + return default_positions + + positions_mock = MagicMock(side_effect=fetch_positions_side_effect) + + default_conf["trading_mode"] = "futures" + default_conf["margin_mode"] = "isolated" + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl", "flx"] + + mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config") + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 + ) + + # Mock super().fetch_positions() to return default positions + mocker.patch(f"{EXMS}.fetch_positions", positions_mock) + + positions = exchange.fetch_positions() + + assert log_has_re("Could not fetch positions from HIP-3 .*", caplog) + + # Should have all positions combined (default + HIP-3) + assert len(positions) == 3 + assert any(p["symbol"] == "BTC/USDC:USDC" for p in positions) + assert any(p["symbol"] == "XYZ-AAPL/USDC:USDC" for p in positions) + assert any(p["symbol"] == "VNTL-SPACEX/USDH:USDH" for p in positions) + + # Verify API calls (xyz + vntl, default is mocked separately) + assert positions_mock.call_count == 4 + + +def test_hyperliquid_market_is_tradable(default_conf_usdt, mocker, markets_hip3): + """Test market_is_tradable filters HIP-3 markets correctly.""" + default_conf_usdt["stake_currency"] = "USDC" + default_conf_usdt["trading_mode"] = "futures" + default_conf_usdt["margin_mode"] = "isolated" + api_mock = MagicMock() + api_mock.load_markets = get_mock_coro(return_value=markets_hip3) + api_mock.markets = markets_hip3 + # Mock parent call - we only want to test hyperliquid specifics here. + mocker.patch(f"{EXMS}.market_is_tradable", return_value=True) + + # Test 1: No HIP-3 DEXes configured - only default markets tradable + default_conf_usdt["exchange"]["hip3_dexes"] = [] + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False + ) + + assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["ETH/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["XYZ-AAPL/USDC:USDC"]) is False + assert exchange.market_is_tradable(markets_hip3["XYZ-TSLA/USDC:USDC"]) is False + assert exchange.market_is_tradable(markets_hip3["VNTL-SPACEX/USDH:USDH"]) is False + assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is False + + # Test 2: Only 'xyz' configured - default + xyz markets tradable + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"] + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False + ) + + assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["ETH/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["XYZ-AAPL/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["XYZ-TSLA/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["VNTL-SPACEX/USDH:USDH"]) is False + assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is False + + # Test 3: 'xyz' and 'vntl' configured - default + xyz + vntl markets tradable + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz", "flx"] + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False + ) + + assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["ETH/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["XYZ-AAPL/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["XYZ-TSLA/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["VNTL-SPACEX/USDH:USDH"]) is False + assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is True + + # Use USDH stake currency to enable VNTL markets + default_conf_usdt["exchange"]["hip3_dexes"] = ["vntl"] + default_conf_usdt["stake_currency"] = "USDH" + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False + ) + assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["ETH/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets_hip3["XYZ-AAPL/USDC:USDC"]) is False + assert exchange.market_is_tradable(markets_hip3["XYZ-TSLA/USDC:USDC"]) is False + assert exchange.market_is_tradable(markets_hip3["VNTL-SPACEX/USDH:USDH"]) is True + assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is False diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 531fec30775..1b73e9ec69b 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -523,6 +523,8 @@ "candle_count": 5000, "orderbook_max_entries": 20, "futures_pair": "BTC/USDC:USDC", + # Assert that HIP3 pairs are fetched as part of load_markets + "futures_alt_pairs": ["XYZ-NVDA/USDC:USDC", "VNTL-ANTHROPIC/USDH:USDH"], "hasQuoteVolumeFutures": True, "leverage_tiers_public": False, "leverage_in_spot_market": False, diff --git a/tests/exchange_online/test_ccxt_compat.py b/tests/exchange_online/test_ccxt_compat.py index d0dc715b989..10ca5d73095 100644 --- a/tests/exchange_online/test_ccxt_compat.py +++ b/tests/exchange_online/test_ccxt_compat.py @@ -67,12 +67,14 @@ def test_ohlcv_limit_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE): def test_load_markets_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE): exchange, exchangename = exchange_futures pair = EXCHANGES[exchangename]["pair"] - pair = EXCHANGES[exchangename].get("futures_pair", pair) + pair1 = EXCHANGES[exchangename].get("futures_pair", pair) + alternative_pairs = EXCHANGES[exchangename].get("futures_alt_pairs", []) markets = exchange.markets - assert pair in markets - assert isinstance(markets[pair], dict) + for pair in [pair1] + alternative_pairs: + assert pair in markets, f"Futures pair {pair} not found in markets" + assert isinstance(markets[pair], dict) - assert exchange.market_is_future(markets[pair]) + assert exchange.market_is_future(markets[pair]) def test_ccxt_order_parse(self, exchange: EXCHANGE_FIXTURE_TYPE): exch, exchange_name = exchange