From 670999abdaa20aa00d712d49173ad8d2cd6a797e Mon Sep 17 00:00:00 2001 From: Ingo Date: Wed, 3 Dec 2025 05:30:36 +0100 Subject: [PATCH 01/34] compatible with HIP3 --- freqtrade/exchange/hyperliquid.py | 167 +++++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 4 deletions(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 212505b7285..49e9c11d04c 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -9,7 +9,7 @@ from freqtrade.enums import MarginMode, TradingMode from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exchange import Exchange -from freqtrade.exchange.exchange_types import CcxtOrder, FtHas +from freqtrade.exchange.exchange_types import CcxtOrder, CcxtPosition, FtHas from freqtrade.util.datetime_helpers import dt_from_ts @@ -59,10 +59,169 @@ def _ccxt_config(self) -> dict: def market_is_tradable(self, market: dict[str, Any]) -> bool: parent_check = super().market_is_tradable(market) + hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) - # 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"] + # Allow HIP-3 markets (with ':' in base) only if hip3_dexes configured + if ":" in market["base"]: + return parent_check and bool(hip3_dexes) + + return parent_check + + def _fetch_hip3_balances(self, hip3_dexes: list[str], base_balance: dict) -> dict: + """Fetch balances from configured HIP-3 DEXes and merge them.""" + logger.info(f"Fetching balances from {len(hip3_dexes)} HIP-3 DEX(es): {hip3_dexes}") + + for dex in hip3_dexes: + try: + logger.debug(f"Fetching balance for HIP-3 DEX: {dex}") + dex_balance = self._api.fetch_balance({"dex": dex}) + + if not dex_balance or "info" not in dex_balance: + logger.error( + f"HIP-3 DEX '{dex}' returned invalid response. Check configuration." + ) + continue + + base_balance = self._merge_hip3_balances(base_balance, dex_balance) + + positions_count = len(dex_balance.get("info", {}).get("assetPositions", [])) + if positions_count > 0: + logger.info(f"Merged {positions_count} position(s) from HIP-3 DEX '{dex}'") + else: + logger.warning(f"HIP-3 DEX '{dex}' returned no positions") + + except Exception as e: + logger.error(f"Could not fetch balance for HIP-3 DEX '{dex}': {e}") + + return base_balance + + def get_balances(self) -> dict: + """Fetch balance including HIP-3 DEX balances if configured.""" + balances = super().get_balances() + + # Fetch raw balance info (CCXT normalizes and removes 'info') + try: + raw_balance = self._api.fetch_balance() + if "info" in raw_balance: + balances["info"] = raw_balance["info"] + except Exception as e: + logger.warning(f"Could not fetch raw balance info: {e}") + + # Fetch HIP-3 DEX balances if configured + hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) + if hip3_dexes: + balances = self._fetch_hip3_balances(hip3_dexes, balances) + + return balances + + def _merge_hip3_balances(self, base_balance: dict, new_balance: dict) -> dict: + """Merge balances from different Hyperliquid DEXes.""" + # Merge assetPositions (raw API response) + if "info" in new_balance and "assetPositions" in new_balance.get("info", {}): + if "info" not in base_balance: + base_balance["info"] = {} + if "assetPositions" not in base_balance["info"]: + base_balance["info"]["assetPositions"] = [] + + new_positions = new_balance["info"]["assetPositions"] + base_balance["info"]["assetPositions"].extend(new_positions) + logger.debug(f"Merged {len(new_positions)} asset position(s) from HIP-3 DEX") + + # Merge normalized CCXT balance structure (skip standard fields) + for currency, amount_info in new_balance.items(): + if currency in ["info", "USDC", "free", "used", "total"]: + continue + + if isinstance(amount_info, dict): + normalized_currency = currency.upper() if ":" in currency else currency + base_balance[normalized_currency] = amount_info + logger.debug(f"Added balance for currency: {normalized_currency}") + + return base_balance + + def fetch_positions(self, pair: str | None = None) -> list[CcxtPosition]: + """ + Fetch positions including HIP-3 positions from assetPositions. + + Override standard fetch_positions to add HIP-3 equity positions + which are not returned by the standard CCXT fetch_positions call. + """ + positions = super().fetch_positions(pair) + + hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) + if not hip3_dexes: + return positions + + try: + balances = self.get_balances() + hip3_positions = self._parse_hip3_positions(balances) + + if hip3_positions: + positions.extend(hip3_positions) + logger.debug(f"Added {len(hip3_positions)} HIP-3 position(s)") + + except Exception as e: + logger.warning(f"Could not fetch HIP-3 positions: {e}") + + return positions + + def _parse_hip3_positions(self, balances: dict) -> list[CcxtPosition]: + """ + Parse HIP-3 positions from balance info into CCXT position format. + + HIP-3 positions are stored in assetPositions with a colon in the coin name + (e.g., 'xyz:TSLA'). This method converts them to the standard CCXT format + that Freqtrade expects. + """ + hip3_positions: list[CcxtPosition] = [] + + asset_positions = balances.get("info", {}).get("assetPositions", []) + if not isinstance(asset_positions, list): + return hip3_positions + + for asset_pos in asset_positions: + position = asset_pos.get("position", {}) + if not isinstance(position, dict): + continue + + coin = position.get("coin", "") + if not coin or ":" not in coin: + continue # Skip non-HIP-3 positions + + szi = float(position.get("szi", 0)) + if szi == 0: + continue # Skip empty positions + + # Parse leverage + leverage_info = position.get("leverage", {}) + if isinstance(leverage_info, dict): + leverage = float(leverage_info.get("value", 1)) + else: + leverage = 1.0 + + # Parse collateral + collateral = float(position.get("marginUsed", 0)) + + # Convert to pair format: xyz:TSLA → XYZ-TSLA/USDC:USDC + symbol = f"{coin.upper().replace(':', '-')}/USDC:USDC" + side = "short" if szi < 0 else "long" + contracts = abs(szi) + + hip3_positions.append( + { + "symbol": symbol, + "contracts": contracts, + "side": side, + "collateral": collateral, + "initialMargin": collateral, + "leverage": leverage, + "liquidationPrice": None, + } + ) + + logger.debug(f"Parsed HIP-3 position: {symbol} = {contracts} contracts {side}") + + return hip3_positions def get_max_leverage(self, pair: str, stake_amount: float | None) -> float: # There are no leverage tiers From 7a23da4deb3800108f9fc3392cf27697cd3894ec Mon Sep 17 00:00:00 2001 From: Ingo Date: Wed, 3 Dec 2025 05:32:31 +0100 Subject: [PATCH 02/34] only use HIP3_dexes with hyperliquid validation --- freqtrade/configuration/config_validation.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index 9e9f0ada9f8..142dc8b4057 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -168,6 +168,15 @@ def _validate_edge(conf: dict[str, Any]) -> None: ) +def _validate_hyperliquid_settings(conf: dict[str, Any]) -> None: + exchange_conf = conf.get("exchange", {}) + exchange_name = exchange_conf.get("name") + hip3_dexes = exchange_conf.get("hip3_dexes") + + if hip3_dexes and exchange_name != "hyperliquid": + raise ConfigurationError('"hip3_dexes" is only supported If Exchange is hyperliquid') + + def _validate_whitelist(conf: dict[str, Any]) -> None: """ Dynamic whitelist does not require pair_whitelist to be set - however StaticWhitelist does. From c892b07eef36a33081e6ea3096ba71ef61608b9b Mon Sep 17 00:00:00 2001 From: Ingo Date: Wed, 3 Dec 2025 06:22:44 +0100 Subject: [PATCH 03/34] compatible with HIP3 --- freqtrade/exchange/hyperliquid.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 49e9c11d04c..c03c67d23f9 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -146,7 +146,15 @@ def fetch_positions(self, pair: str | None = None) -> list[CcxtPosition]: Override standard fetch_positions to add HIP-3 equity positions which are not returned by the standard CCXT fetch_positions call. """ - positions = super().fetch_positions(pair) + # Fetch standard positions directly from CCXT to avoid parameter issues + try: + if pair: + positions = self._api.fetch_positions([pair]) + else: + positions = self._api.fetch_positions() + except Exception as e: + logger.warning(f"Could not fetch standard positions: {e}") + positions = [] hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) if not hip3_dexes: From 3ebc7bfcfce12048cd3799445242708562101027 Mon Sep 17 00:00:00 2001 From: Ingo Date: Wed, 3 Dec 2025 07:02:30 +0100 Subject: [PATCH 04/34] no parsing direct position call --- freqtrade/exchange/hyperliquid.py | 132 +++++++++--------------------- 1 file changed, 40 insertions(+), 92 deletions(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index c03c67d23f9..db2cd9e11b2 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -67,6 +67,30 @@ def market_is_tradable(self, market: dict[str, Any]) -> bool: return parent_check + def get_balances(self) -> dict: + """ + Fetch balance including HIP-3 DEX balances if configured. + + For Hyperliquid, this fetches balances from the default DEX and any + configured HIP-3 DEXes (e.g., 'xyz' for equities). + """ + balances = super().get_balances() + + # Fetch raw balance info (CCXT normalizes and removes 'info') + try: + raw_balance = self._api.fetch_balance() + if "info" in raw_balance: + balances["info"] = raw_balance["info"] + except Exception as e: + logger.warning(f"Could not fetch raw balance info: {e}") + + # Fetch HIP-3 DEX balances if configured + hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) + if hip3_dexes: + balances = self._fetch_hip3_balances(hip3_dexes, balances) + + return balances + def _fetch_hip3_balances(self, hip3_dexes: list[str], base_balance: dict) -> dict: """Fetch balances from configured HIP-3 DEXes and merge them.""" logger.info(f"Fetching balances from {len(hip3_dexes)} HIP-3 DEX(es): {hip3_dexes}") @@ -95,25 +119,6 @@ def _fetch_hip3_balances(self, hip3_dexes: list[str], base_balance: dict) -> dic return base_balance - def get_balances(self) -> dict: - """Fetch balance including HIP-3 DEX balances if configured.""" - balances = super().get_balances() - - # Fetch raw balance info (CCXT normalizes and removes 'info') - try: - raw_balance = self._api.fetch_balance() - if "info" in raw_balance: - balances["info"] = raw_balance["info"] - except Exception as e: - logger.warning(f"Could not fetch raw balance info: {e}") - - # Fetch HIP-3 DEX balances if configured - hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) - if hip3_dexes: - balances = self._fetch_hip3_balances(hip3_dexes, balances) - - return balances - def _merge_hip3_balances(self, base_balance: dict, new_balance: dict) -> dict: """Merge balances from different Hyperliquid DEXes.""" # Merge assetPositions (raw API response) @@ -141,12 +146,13 @@ def _merge_hip3_balances(self, base_balance: dict, new_balance: dict) -> dict: def fetch_positions(self, pair: str | None = None) -> list[CcxtPosition]: """ - Fetch positions including HIP-3 positions from assetPositions. + Fetch positions including HIP-3 positions. - Override standard fetch_positions to add HIP-3 equity positions - which are not returned by the standard CCXT fetch_positions call. + Hyperliquid supports fetching positions from different DEXes using the + 'dex' parameter. This method fetches positions from the default DEX and + all configured HIP-3 DEXes. """ - # Fetch standard positions directly from CCXT to avoid parameter issues + # Fetch standard positions from default DEX try: if pair: positions = self._api.fetch_positions([pair]) @@ -156,81 +162,23 @@ def fetch_positions(self, pair: str | None = None) -> list[CcxtPosition]: logger.warning(f"Could not fetch standard positions: {e}") positions = [] + # Fetch HIP-3 positions if configured hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) - if not hip3_dexes: - return positions - - try: - balances = self.get_balances() - hip3_positions = self._parse_hip3_positions(balances) + if hip3_dexes: + for dex in hip3_dexes: + try: + logger.debug(f"Fetching positions from HIP-3 DEX: {dex}") + hip3_positions = self._api.fetch_positions(params={"dex": dex}) - if hip3_positions: - positions.extend(hip3_positions) - logger.debug(f"Added {len(hip3_positions)} HIP-3 position(s)") + if hip3_positions: + positions.extend(hip3_positions) + logger.debug(f"Added {len(hip3_positions)} position(s) from DEX '{dex}'") - except Exception as e: - logger.warning(f"Could not fetch HIP-3 positions: {e}") + except Exception as e: + logger.error(f"Could not fetch positions from HIP-3 DEX '{dex}': {e}") return positions - def _parse_hip3_positions(self, balances: dict) -> list[CcxtPosition]: - """ - Parse HIP-3 positions from balance info into CCXT position format. - - HIP-3 positions are stored in assetPositions with a colon in the coin name - (e.g., 'xyz:TSLA'). This method converts them to the standard CCXT format - that Freqtrade expects. - """ - hip3_positions: list[CcxtPosition] = [] - - asset_positions = balances.get("info", {}).get("assetPositions", []) - if not isinstance(asset_positions, list): - return hip3_positions - - for asset_pos in asset_positions: - position = asset_pos.get("position", {}) - if not isinstance(position, dict): - continue - - coin = position.get("coin", "") - if not coin or ":" not in coin: - continue # Skip non-HIP-3 positions - - szi = float(position.get("szi", 0)) - if szi == 0: - continue # Skip empty positions - - # Parse leverage - leverage_info = position.get("leverage", {}) - if isinstance(leverage_info, dict): - leverage = float(leverage_info.get("value", 1)) - else: - leverage = 1.0 - - # Parse collateral - collateral = float(position.get("marginUsed", 0)) - - # Convert to pair format: xyz:TSLA → XYZ-TSLA/USDC:USDC - symbol = f"{coin.upper().replace(':', '-')}/USDC:USDC" - side = "short" if szi < 0 else "long" - contracts = abs(szi) - - hip3_positions.append( - { - "symbol": symbol, - "contracts": contracts, - "side": side, - "collateral": collateral, - "initialMargin": collateral, - "leverage": leverage, - "liquidationPrice": None, - } - ) - - logger.debug(f"Parsed HIP-3 position: {symbol} = {contracts} contracts {side}") - - return hip3_positions - def get_max_leverage(self, pair: str, stake_amount: float | None) -> float: # There are no leverage tiers if self.trading_mode == TradingMode.FUTURES: From 922d5893f810c35ae13291dc90c411fbc2075192 Mon Sep 17 00:00:00 2001 From: Ingo Date: Thu, 4 Dec 2025 07:06:52 +0100 Subject: [PATCH 05/34] newest ccxt --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4efa1fa8737..e75918c8853 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16 ta-lib==0.6.8 technical==1.5.3 -ccxt==4.5.22 +ccxt==4.5.24 cryptography==46.0.3 aiohttp==3.13.2 SQLAlchemy==2.0.44 From 2045fa6427cbbc3a45dc546794f183df2c6b9971 Mon Sep 17 00:00:00 2001 From: igi01 <88946458+igi01@users.noreply.github.com> Date: Mon, 15 Dec 2025 18:59:54 +0100 Subject: [PATCH 06/34] Delete freqtrade/configuration/config_validation.py validation will be included in hyperliquid.py --- freqtrade/configuration/config_validation.py | 431 ------------------- 1 file changed, 431 deletions(-) delete mode 100644 freqtrade/configuration/config_validation.py diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py deleted file mode 100644 index 142dc8b4057..00000000000 --- a/freqtrade/configuration/config_validation.py +++ /dev/null @@ -1,431 +0,0 @@ -import logging -from collections import Counter -from copy import deepcopy -from typing import Any - -from jsonschema import Draft4Validator, validators -from jsonschema.exceptions import ValidationError, best_match - -from freqtrade.config_schema.config_schema import ( - CONF_SCHEMA, - SCHEMA_BACKTEST_REQUIRED, - SCHEMA_BACKTEST_REQUIRED_FINAL, - SCHEMA_MINIMAL_REQUIRED, - SCHEMA_MINIMAL_WEBSERVER, - SCHEMA_TRADE_REQUIRED, -) -from freqtrade.configuration.deprecated_settings import process_deprecated_setting -from freqtrade.constants import UNLIMITED_STAKE_AMOUNT -from freqtrade.enums import RunMode, TradingMode -from freqtrade.exceptions import ConfigurationError - - -logger = logging.getLogger(__name__) - - -def _extend_validator(validator_class): - """ - Extended validator for the Freqtrade configuration JSON Schema. - Currently it only handles defaults for subschemas. - """ - validate_properties = validator_class.VALIDATORS["properties"] - - def set_defaults(validator, properties, instance, schema): - for prop, subschema in properties.items(): - if "default" in subschema: - instance.setdefault(prop, subschema["default"]) - - yield from validate_properties(validator, properties, instance, schema) - - return validators.extend(validator_class, {"properties": set_defaults}) - - -FreqtradeValidator = _extend_validator(Draft4Validator) - - -def validate_config_schema(conf: dict[str, Any], preliminary: bool = False) -> dict[str, Any]: - """ - Validate the configuration follow the Config Schema - :param conf: Config in JSON format - :return: Returns the config if valid, otherwise throw an exception - """ - conf_schema = deepcopy(CONF_SCHEMA) - if conf.get("runmode", RunMode.OTHER) in (RunMode.DRY_RUN, RunMode.LIVE): - conf_schema["required"] = SCHEMA_TRADE_REQUIRED - elif conf.get("runmode", RunMode.OTHER) in (RunMode.BACKTEST, RunMode.HYPEROPT): - if preliminary: - conf_schema["required"] = SCHEMA_BACKTEST_REQUIRED - else: - conf_schema["required"] = SCHEMA_BACKTEST_REQUIRED_FINAL - elif conf.get("runmode", RunMode.OTHER) == RunMode.WEBSERVER: - conf_schema["required"] = SCHEMA_MINIMAL_WEBSERVER - else: - conf_schema["required"] = SCHEMA_MINIMAL_REQUIRED - try: - FreqtradeValidator(conf_schema).validate(conf) - return conf - except ValidationError as e: - logger.critical(f"Invalid configuration. Reason: {e}") - result = best_match(FreqtradeValidator(conf_schema).iter_errors(conf)) - raise ConfigurationError(result.message) - - -def validate_config_consistency(conf: dict[str, Any], *, preliminary: bool = False) -> None: - """ - Validate the configuration consistency. - Should be ran after loading both configuration and strategy, - since strategies can set certain configuration settings too. - :param conf: Config in JSON format - :return: Returns None if everything is ok, otherwise throw an ConfigurationError - """ - - # validating trailing stoploss - _validate_trailing_stoploss(conf) - _validate_price_config(conf) - _validate_edge(conf) - _validate_whitelist(conf) - _validate_unlimited_amount(conf) - _validate_ask_orderbook(conf) - _validate_freqai_hyperopt(conf) - _validate_freqai_backtest(conf) - _validate_freqai_include_timeframes(conf, preliminary=preliminary) - _validate_consumers(conf) - validate_migrated_strategy_settings(conf) - _validate_orderflow(conf) - - # validate configuration before returning - logger.info("Validating configuration ...") - validate_config_schema(conf, preliminary=preliminary) - - -def _validate_unlimited_amount(conf: dict[str, Any]) -> None: - """ - Either max_open_trades or stake_amount need to be set. - :raise: ConfigurationError if config validation failed - """ - if ( - conf.get("max_open_trades") == float("inf") or conf.get("max_open_trades") == -1 - ) and conf.get("stake_amount") == UNLIMITED_STAKE_AMOUNT: - raise ConfigurationError("`max_open_trades` and `stake_amount` cannot both be unlimited.") - - -def _validate_price_config(conf: dict[str, Any]) -> None: - """ - When using market orders, price sides must be using the "other" side of the price - """ - if conf.get("order_types", {}).get("entry") == "market" and conf.get("entry_pricing", {}).get( - "price_side" - ) not in ("ask", "other"): - raise ConfigurationError('Market entry orders require entry_pricing.price_side = "other".') - - if conf.get("order_types", {}).get("exit") == "market" and conf.get("exit_pricing", {}).get( - "price_side" - ) not in ("bid", "other"): - raise ConfigurationError('Market exit orders require exit_pricing.price_side = "other".') - - -def _validate_trailing_stoploss(conf: dict[str, Any]) -> None: - if conf.get("stoploss") == 0.0: - raise ConfigurationError( - "The config stoploss needs to be different from 0 to avoid problems with sell orders." - ) - # Skip if trailing stoploss is not activated - if not conf.get("trailing_stop", False): - return - - tsl_positive = float(conf.get("trailing_stop_positive", 0)) - tsl_offset = float(conf.get("trailing_stop_positive_offset", 0)) - tsl_only_offset = conf.get("trailing_only_offset_is_reached", False) - - if tsl_only_offset: - if tsl_positive == 0.0: - raise ConfigurationError( - "The config trailing_only_offset_is_reached needs " - "trailing_stop_positive_offset to be more than 0 in your config." - ) - if tsl_positive > 0 and 0 < tsl_offset <= tsl_positive: - raise ConfigurationError( - "The config trailing_stop_positive_offset needs " - "to be greater than trailing_stop_positive in your config." - ) - - # Fetch again without default - if "trailing_stop_positive" in conf and float(conf["trailing_stop_positive"]) == 0.0: - raise ConfigurationError( - "The config trailing_stop_positive needs to be different from 0 " - "to avoid problems with sell orders." - ) - - -def _validate_edge(conf: dict[str, Any]) -> None: - """ - Edge and Dynamic whitelist should not both be enabled, since edge overrides dynamic whitelists. - """ - - if conf.get("edge", {}).get("enabled"): - raise ConfigurationError( - "Edge is no longer supported and has been removed from Freqtrade with 2025.6." - ) - - -def _validate_hyperliquid_settings(conf: dict[str, Any]) -> None: - exchange_conf = conf.get("exchange", {}) - exchange_name = exchange_conf.get("name") - hip3_dexes = exchange_conf.get("hip3_dexes") - - if hip3_dexes and exchange_name != "hyperliquid": - raise ConfigurationError('"hip3_dexes" is only supported If Exchange is hyperliquid') - - -def _validate_whitelist(conf: dict[str, Any]) -> None: - """ - Dynamic whitelist does not require pair_whitelist to be set - however StaticWhitelist does. - """ - if conf.get("runmode", RunMode.OTHER) in [ - RunMode.OTHER, - RunMode.PLOT, - RunMode.UTIL_NO_EXCHANGE, - RunMode.UTIL_EXCHANGE, - ]: - return - - for pl in conf.get("pairlists", [{"method": "StaticPairList"}]): - if ( - isinstance(pl, dict) - and pl.get("method") == "StaticPairList" - and not conf.get("exchange", {}).get("pair_whitelist") - ): - raise ConfigurationError("StaticPairList requires pair_whitelist to be set.") - - -def _validate_ask_orderbook(conf: dict[str, Any]) -> None: - ask_strategy = conf.get("exit_pricing", {}) - ob_min = ask_strategy.get("order_book_min") - ob_max = ask_strategy.get("order_book_max") - if ob_min is not None and ob_max is not None and ask_strategy.get("use_order_book"): - if ob_min != ob_max: - raise ConfigurationError( - "Using order_book_max != order_book_min in exit_pricing is no longer supported." - "Please pick one value and use `order_book_top` in the future." - ) - else: - # Move value to order_book_top - ask_strategy["order_book_top"] = ob_min - logger.warning( - "DEPRECATED: " - "Please use `order_book_top` instead of `order_book_min` and `order_book_max` " - "for your `exit_pricing` configuration." - ) - - -def validate_migrated_strategy_settings(conf: dict[str, Any]) -> None: - _validate_time_in_force(conf) - _validate_order_types(conf) - _validate_unfilledtimeout(conf) - _validate_pricing_rules(conf) - _strategy_settings(conf) - - -def _validate_time_in_force(conf: dict[str, Any]) -> None: - time_in_force = conf.get("order_time_in_force", {}) - if "buy" in time_in_force or "sell" in time_in_force: - if conf.get("trading_mode", TradingMode.SPOT) != TradingMode.SPOT: - raise ConfigurationError( - "Please migrate your time_in_force settings to use 'entry' and 'exit'." - ) - else: - logger.warning( - "DEPRECATED: Using 'buy' and 'sell' for time_in_force is deprecated." - "Please migrate your time_in_force settings to use 'entry' and 'exit'." - ) - process_deprecated_setting( - conf, "order_time_in_force", "buy", "order_time_in_force", "entry" - ) - - process_deprecated_setting( - conf, "order_time_in_force", "sell", "order_time_in_force", "exit" - ) - - -def _validate_order_types(conf: dict[str, Any]) -> None: - order_types = conf.get("order_types", {}) - old_order_types = [ - "buy", - "sell", - "emergencysell", - "forcebuy", - "forcesell", - "emergencyexit", - "forceexit", - "forceentry", - ] - if any(x in order_types for x in old_order_types): - if conf.get("trading_mode", TradingMode.SPOT) != TradingMode.SPOT: - raise ConfigurationError( - "Please migrate your order_types settings to use the new wording." - ) - else: - logger.warning( - "DEPRECATED: Using 'buy' and 'sell' for order_types is deprecated." - "Please migrate your order_types settings to use 'entry' and 'exit' wording." - ) - for o, n in [ - ("buy", "entry"), - ("sell", "exit"), - ("emergencysell", "emergency_exit"), - ("forcesell", "force_exit"), - ("forcebuy", "force_entry"), - ("emergencyexit", "emergency_exit"), - ("forceexit", "force_exit"), - ("forceentry", "force_entry"), - ]: - process_deprecated_setting(conf, "order_types", o, "order_types", n) - - -def _validate_unfilledtimeout(conf: dict[str, Any]) -> None: - unfilledtimeout = conf.get("unfilledtimeout", {}) - if any(x in unfilledtimeout for x in ["buy", "sell"]): - if conf.get("trading_mode", TradingMode.SPOT) != TradingMode.SPOT: - raise ConfigurationError( - "Please migrate your unfilledtimeout settings to use the new wording." - ) - else: - logger.warning( - "DEPRECATED: Using 'buy' and 'sell' for unfilledtimeout is deprecated." - "Please migrate your unfilledtimeout settings to use 'entry' and 'exit' wording." - ) - for o, n in [ - ("buy", "entry"), - ("sell", "exit"), - ]: - process_deprecated_setting(conf, "unfilledtimeout", o, "unfilledtimeout", n) - - -def _validate_pricing_rules(conf: dict[str, Any]) -> None: - if conf.get("ask_strategy") or conf.get("bid_strategy"): - if conf.get("trading_mode", TradingMode.SPOT) != TradingMode.SPOT: - raise ConfigurationError("Please migrate your pricing settings to use the new wording.") - else: - logger.warning( - "DEPRECATED: Using 'ask_strategy' and 'bid_strategy' is deprecated." - "Please migrate your settings to use 'entry_pricing' and 'exit_pricing'." - ) - conf["entry_pricing"] = {} - for obj in list(conf.get("bid_strategy", {}).keys()): - if obj == "ask_last_balance": - process_deprecated_setting( - conf, "bid_strategy", obj, "entry_pricing", "price_last_balance" - ) - else: - process_deprecated_setting(conf, "bid_strategy", obj, "entry_pricing", obj) - del conf["bid_strategy"] - - conf["exit_pricing"] = {} - for obj in list(conf.get("ask_strategy", {}).keys()): - if obj == "bid_last_balance": - process_deprecated_setting( - conf, "ask_strategy", obj, "exit_pricing", "price_last_balance" - ) - else: - process_deprecated_setting(conf, "ask_strategy", obj, "exit_pricing", obj) - del conf["ask_strategy"] - - -def _validate_freqai_hyperopt(conf: dict[str, Any]) -> None: - freqai_enabled = conf.get("freqai", {}).get("enabled", False) - analyze_per_epoch = conf.get("analyze_per_epoch", False) - if analyze_per_epoch and freqai_enabled: - raise ConfigurationError( - "Using analyze-per-epoch parameter is not supported with a FreqAI strategy." - ) - - -def _validate_freqai_include_timeframes(conf: dict[str, Any], preliminary: bool) -> None: - freqai_enabled = conf.get("freqai", {}).get("enabled", False) - if freqai_enabled: - main_tf = conf.get("timeframe", "5m") - freqai_include_timeframes = ( - conf.get("freqai", {}).get("feature_parameters", {}).get("include_timeframes", []) - ) - - from freqtrade.exchange import timeframe_to_seconds - - main_tf_s = timeframe_to_seconds(main_tf) - offending_lines = [] - for tf in freqai_include_timeframes: - tf_s = timeframe_to_seconds(tf) - if tf_s < main_tf_s: - offending_lines.append(tf) - if offending_lines: - raise ConfigurationError( - f"Main timeframe of {main_tf} must be smaller or equal to FreqAI " - f"`include_timeframes`.Offending include-timeframes: {', '.join(offending_lines)}" - ) - - # Ensure that the base timeframe is included in the include_timeframes list - if not preliminary and main_tf not in freqai_include_timeframes: - feature_parameters = conf.get("freqai", {}).get("feature_parameters", {}) - include_timeframes = [main_tf, *freqai_include_timeframes] - conf.get("freqai", {}).get("feature_parameters", {}).update( - {**feature_parameters, "include_timeframes": include_timeframes} - ) - - -def _validate_freqai_backtest(conf: dict[str, Any]) -> None: - if conf.get("runmode", RunMode.OTHER) == RunMode.BACKTEST: - freqai_enabled = conf.get("freqai", {}).get("enabled", False) - timerange = conf.get("timerange") - freqai_backtest_live_models = conf.get("freqai_backtest_live_models", False) - if freqai_backtest_live_models and freqai_enabled and timerange: - raise ConfigurationError( - "Using timerange parameter is not supported with " - "--freqai-backtest-live-models parameter." - ) - - if freqai_backtest_live_models and not freqai_enabled: - raise ConfigurationError( - "Using --freqai-backtest-live-models parameter is only " - "supported with a FreqAI strategy." - ) - - if freqai_enabled and not freqai_backtest_live_models and not timerange: - raise ConfigurationError( - "Please pass --timerange if you intend to use FreqAI for backtesting." - ) - - -def _validate_consumers(conf: dict[str, Any]) -> None: - emc_conf = conf.get("external_message_consumer", {}) - if emc_conf.get("enabled", False): - if len(emc_conf.get("producers", [])) < 1: - raise ConfigurationError("You must specify at least 1 Producer to connect to.") - - producer_names = [p["name"] for p in emc_conf.get("producers", [])] - duplicates = [item for item, count in Counter(producer_names).items() if count > 1] - if duplicates: - raise ConfigurationError( - f"Producer names must be unique. Duplicate: {', '.join(duplicates)}" - ) - if conf.get("process_only_new_candles", True): - # Warning here or require it? - logger.warning( - "To receive best performance with external data, " - "please set `process_only_new_candles` to False" - ) - - -def _validate_orderflow(conf: dict[str, Any]) -> None: - if conf.get("exchange", {}).get("use_public_trades"): - if "orderflow" not in conf: - raise ConfigurationError( - "Orderflow is a required configuration key when using public trades." - ) - - -def _strategy_settings(conf: dict[str, Any]) -> None: - process_deprecated_setting(conf, None, "use_sell_signal", None, "use_exit_signal") - process_deprecated_setting(conf, None, "sell_profit_only", None, "exit_profit_only") - process_deprecated_setting(conf, None, "sell_profit_offset", None, "exit_profit_offset") - process_deprecated_setting( - conf, None, "ignore_roi_if_buy_signal", None, "ignore_roi_if_entry_signal" - ) From 789f4aa4f84a89daf11b6a4c3b39ae744f1149f0 Mon Sep 17 00:00:00 2001 From: igi01 <88946458+igi01@users.noreply.github.com> Date: Mon, 15 Dec 2025 19:01:24 +0100 Subject: [PATCH 07/34] Delete requirements.txt Accidentally pushed Version, which was used for testing --- requirements.txt | 62 ------------------------------------------------ 1 file changed, 62 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e75918c8853..00000000000 --- a/requirements.txt +++ /dev/null @@ -1,62 +0,0 @@ -numpy==2.3.5 -pandas==2.3.3 -bottleneck==1.6.0 -numexpr==2.14.1 -# Indicator libraries -ft-pandas-ta==0.3.16 -ta-lib==0.6.8 -technical==1.5.3 - -ccxt==4.5.24 -cryptography==46.0.3 -aiohttp==3.13.2 -SQLAlchemy==2.0.44 -python-telegram-bot==22.5 -# can't be hard-pinned due to telegram-bot pinning httpx with ~ -httpx>=0.24.1 -humanize==4.14.0 -cachetools==6.2.2 -requests==2.32.5 -urllib3==2.5.0 -certifi==2025.11.12 -jsonschema==4.25.1 -tabulate==0.9.0 -pycoingecko==3.2.0 -jinja2==3.1.6 -joblib==1.5.2 -rich==14.2.0 -pyarrow==22.0.0; platform_machine != 'armv7l' - - -# Load ticker files 30% faster -python-rapidjson==1.22 -# Properly format api responses -orjson==3.11.4 - -# Notify systemd -sdnotify==0.3.2 - -# API Server -fastapi==0.122.0 -pydantic==2.12.5 -uvicorn==0.38.0 -pyjwt==2.10.1 -aiofiles==25.1.0 -psutil==7.1.3 - -# Building config files interactively -questionary==2.1.1 -prompt-toolkit==3.0.52 -# Extensions to datetime library -python-dateutil==2.9.0.post0 -pytz==2025.2 - -#Futures -schedule==1.2.2 - -#WS Messages -websockets==15.0.1 -janus==2.0.0 - -ast-comments==1.2.3 -packaging==25.0 From f32d25daac3bdd79238f11fc14b7a1b82dbaec1a Mon Sep 17 00:00:00 2001 From: Ingo Date: Wed, 17 Dec 2025 21:56:07 +0100 Subject: [PATCH 08/34] accidentaly deleted file and not the diff --- requirements.txt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index e75918c8853..d14fddedea4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ ft-pandas-ta==0.3.16 ta-lib==0.6.8 technical==1.5.3 -ccxt==4.5.24 +ccxt==4.5.27 cryptography==46.0.3 aiohttp==3.13.2 SQLAlchemy==2.0.44 @@ -17,7 +17,7 @@ httpx>=0.24.1 humanize==4.14.0 cachetools==6.2.2 requests==2.32.5 -urllib3==2.5.0 +urllib3==2.6.0 certifi==2025.11.12 jsonschema==4.25.1 tabulate==0.9.0 @@ -29,15 +29,15 @@ pyarrow==22.0.0; platform_machine != 'armv7l' # Load ticker files 30% faster -python-rapidjson==1.22 +python-rapidjson==1.23 # Properly format api responses -orjson==3.11.4 +orjson==3.11.5 # Notify systemd sdnotify==0.3.2 # API Server -fastapi==0.122.0 +fastapi==0.124.0 pydantic==2.12.5 uvicorn==0.38.0 pyjwt==2.10.1 From 245ca4bc2ad86129a7a31fe06b54e261ac1bd7f3 Mon Sep 17 00:00:00 2001 From: Ingo Date: Wed, 17 Dec 2025 21:56:59 +0100 Subject: [PATCH 09/34] accidentaly deleted file and not the diff --- freqtrade/configuration/config_validation.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index 142dc8b4057..9e9f0ada9f8 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -168,15 +168,6 @@ def _validate_edge(conf: dict[str, Any]) -> None: ) -def _validate_hyperliquid_settings(conf: dict[str, Any]) -> None: - exchange_conf = conf.get("exchange", {}) - exchange_name = exchange_conf.get("name") - hip3_dexes = exchange_conf.get("hip3_dexes") - - if hip3_dexes and exchange_name != "hyperliquid": - raise ConfigurationError('"hip3_dexes" is only supported If Exchange is hyperliquid') - - def _validate_whitelist(conf: dict[str, Any]) -> None: """ Dynamic whitelist does not require pair_whitelist to be set - however StaticWhitelist does. From 301aff141a351b6374ec4629b016b92b1558e67b Mon Sep 17 00:00:00 2001 From: Ingo Date: Wed, 17 Dec 2025 21:58:10 +0100 Subject: [PATCH 10/34] HIP3 dexes now fetched by market info of specific pairs --- freqtrade/exchange/hyperliquid.py | 138 ++++++++---------------------- 1 file changed, 35 insertions(+), 103 deletions(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index db2cd9e11b2..ac4143ef2df 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -3,7 +3,6 @@ import logging from copy import deepcopy from datetime import datetime -from typing import Any from freqtrade.constants import BuySell from freqtrade.enums import MarginMode, TradingMode @@ -37,9 +36,9 @@ class Hyperliquid(Exchange): "stoploss_order_types": {"limit": "limit"}, "stoploss_blocks_assets": False, "stop_price_prop": "stopPrice", - "funding_fee_timeframe": "1h", "funding_fee_candle_limit": 500, "uses_leverage_tiers": False, + "mark_ohlcv_price": "futures", } _supported_trading_mode_margin_pairs: list[tuple[TradingMode, MarginMode]] = [ @@ -57,125 +56,58 @@ def _ccxt_config(self) -> dict: config.update(super()._ccxt_config) return config - def market_is_tradable(self, market: dict[str, Any]) -> bool: - parent_check = super().market_is_tradable(market) - hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) - - # Allow HIP-3 markets (with ':' in base) only if hip3_dexes configured - if ":" in market["base"]: - return parent_check and bool(hip3_dexes) - - return parent_check - - def get_balances(self) -> dict: + def _get_required_hip3_dexes(self) -> set[str]: """ - Fetch balance including HIP-3 DEX balances if configured. - - For Hyperliquid, this fetches balances from the default DEX and any - configured HIP-3 DEXes (e.g., 'xyz' for equities). + Get HIP-3 DEXes that are needed based on tradable pairs. + Completely automatic - no configuration needed! """ - balances = super().get_balances() + required_dexes = set() - # Fetch raw balance info (CCXT normalizes and removes 'info') - try: - raw_balance = self._api.fetch_balance() - if "info" in raw_balance: - balances["info"] = raw_balance["info"] - except Exception as e: - logger.warning(f"Could not fetch raw balance info: {e}") + for market in self.markets.values(): + if not super().market_is_tradable(market): + continue - # Fetch HIP-3 DEX balances if configured - hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) - if hip3_dexes: - balances = self._fetch_hip3_balances(hip3_dexes, balances) + market_info = market.get("info", {}) + if market_info.get("hip3") is True: + dex = market_info.get("dex") + if dex: + required_dexes.add(dex) - return balances + return required_dexes - def _fetch_hip3_balances(self, hip3_dexes: list[str], base_balance: dict) -> dict: - """Fetch balances from configured HIP-3 DEXes and merge them.""" - logger.info(f"Fetching balances from {len(hip3_dexes)} HIP-3 DEX(es): {hip3_dexes}") + def get_balances(self) -> dict: + """Fetch balances from default DEX and HIP-3 DEXes needed by tradable pairs.""" + balances = super().get_balances() - for dex in hip3_dexes: + for dex in self._get_required_hip3_dexes(): try: - logger.debug(f"Fetching balance for HIP-3 DEX: {dex}") dex_balance = self._api.fetch_balance({"dex": dex}) - if not dex_balance or "info" not in dex_balance: - logger.error( - f"HIP-3 DEX '{dex}' returned invalid response. Check configuration." - ) - continue - - base_balance = self._merge_hip3_balances(base_balance, dex_balance) + for currency, amount_info in dex_balance.items(): + if currency in ["info", "free", "used", "total", "datetime", "timestamp"]: + continue - positions_count = len(dex_balance.get("info", {}).get("assetPositions", [])) - if positions_count > 0: - logger.info(f"Merged {positions_count} position(s) from HIP-3 DEX '{dex}'") - else: - logger.warning(f"HIP-3 DEX '{dex}' returned no positions") + 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}") - return base_balance - - def _merge_hip3_balances(self, base_balance: dict, new_balance: dict) -> dict: - """Merge balances from different Hyperliquid DEXes.""" - # Merge assetPositions (raw API response) - if "info" in new_balance and "assetPositions" in new_balance.get("info", {}): - if "info" not in base_balance: - base_balance["info"] = {} - if "assetPositions" not in base_balance["info"]: - base_balance["info"]["assetPositions"] = [] - - new_positions = new_balance["info"]["assetPositions"] - base_balance["info"]["assetPositions"].extend(new_positions) - logger.debug(f"Merged {len(new_positions)} asset position(s) from HIP-3 DEX") - - # Merge normalized CCXT balance structure (skip standard fields) - for currency, amount_info in new_balance.items(): - if currency in ["info", "USDC", "free", "used", "total"]: - continue - - if isinstance(amount_info, dict): - normalized_currency = currency.upper() if ":" in currency else currency - base_balance[normalized_currency] = amount_info - logger.debug(f"Added balance for currency: {normalized_currency}") - - return base_balance + return balances def fetch_positions(self, pair: str | None = None) -> list[CcxtPosition]: - """ - Fetch positions including HIP-3 positions. + """Fetch positions from default DEX and HIP-3 DEXes needed by tradable pairs.""" + positions = super().fetch_positions(pair) - Hyperliquid supports fetching positions from different DEXes using the - 'dex' parameter. This method fetches positions from the default DEX and - all configured HIP-3 DEXes. - """ - # Fetch standard positions from default DEX - try: - if pair: - positions = self._api.fetch_positions([pair]) - else: - positions = self._api.fetch_positions() - except Exception as e: - logger.warning(f"Could not fetch standard positions: {e}") - positions = [] - - # Fetch HIP-3 positions if configured - hip3_dexes = self._config.get("exchange", {}).get("hip3_dexes", []) - if hip3_dexes: - for dex in hip3_dexes: - try: - logger.debug(f"Fetching positions from HIP-3 DEX: {dex}") - hip3_positions = self._api.fetch_positions(params={"dex": dex}) - - if hip3_positions: - positions.extend(hip3_positions) - logger.debug(f"Added {len(hip3_positions)} position(s) from DEX '{dex}'") - - except Exception as e: - logger.error(f"Could not fetch positions from HIP-3 DEX '{dex}': {e}") + for dex in self._get_required_hip3_dexes(): + try: + positions.extend(self._api.fetch_positions(params={"dex": dex})) + except Exception as e: + logger.error(f"Could not fetch positions from HIP-3 DEX '{dex}': {e}") return positions From 29b7cbbb124bbd43b06384213173e2bee23e68bf Mon Sep 17 00:00:00 2001 From: Ingo Date: Thu, 18 Dec 2025 05:31:55 +0100 Subject: [PATCH 11/34] HIP3 dexes now fetched by market info of specific pairs --- freqtrade/exchange/hyperliquid.py | 1 - 1 file changed, 1 deletion(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index ac4143ef2df..55fae6f3c77 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -59,7 +59,6 @@ def _ccxt_config(self) -> dict: def _get_required_hip3_dexes(self) -> set[str]: """ Get HIP-3 DEXes that are needed based on tradable pairs. - Completely automatic - no configuration needed! """ required_dexes = set() From bcf6c244b0a81238151f4c3cd0678f67b7df58c5 Mon Sep 17 00:00:00 2001 From: Ingo Date: Sun, 21 Dec 2025 07:33:57 +0100 Subject: [PATCH 12/34] Add config validation + market tradable checks --- freqtrade/exchange/hyperliquid.py | 66 +++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 55fae6f3c77..170bc400a01 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -3,6 +3,7 @@ import logging from copy import deepcopy from datetime import datetime +from typing import Any from freqtrade.constants import BuySell from freqtrade.enums import MarginMode, TradingMode @@ -56,29 +57,62 @@ def _ccxt_config(self) -> dict: config.update(super()._ccxt_config) return config - def _get_required_hip3_dexes(self) -> set[str]: - """ - Get HIP-3 DEXes that are needed based on tradable pairs. - """ - required_dexes = set() + 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: + return + + if len(configured) > 2: + raise OperationalException( + f"Maximum 2 HIP-3 DEXes allowed, but {len(configured)} configured: {configured}. " + f"Remove extra DEXes from your 'hip3_dexes' configuration!" + ) + + if not self.markets: + return + + available = { + m.get("info", {}).get("dex") + for m in self.markets.values() + if m.get("info", {}).get("hip3") + } + available.discard(None) + + invalid = set(configured) - available + if invalid: + raise OperationalException( + f"Invalid HIP-3 DEXes configured: {sorted(invalid)}. " + f"Available DEXes: {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.""" + super().market_is_tradable(market) - for market in self.markets.values(): - if not super().market_is_tradable(market): - continue + market_info = market.get("info", {}) + if market_info.get("hip3"): + configured = self._get_configured_hip3_dexes() + if not configured: + return False - market_info = market.get("info", {}) - if market_info.get("hip3") is True: - dex = market_info.get("dex") - if dex: - required_dexes.add(dex) + market_dex = market_info.get("dex") + return market_dex in configured - return required_dexes + return True def get_balances(self) -> dict: """Fetch balances from default DEX and HIP-3 DEXes needed by tradable pairs.""" balances = super().get_balances() - for dex in self._get_required_hip3_dexes(): + for dex in self._get_configured_hip3_dexes(): try: dex_balance = self._api.fetch_balance({"dex": dex}) @@ -102,7 +136,7 @@ def fetch_positions(self, pair: str | None = None) -> list[CcxtPosition]: """Fetch positions from default DEX and HIP-3 DEXes needed by tradable pairs.""" positions = super().fetch_positions(pair) - for dex in self._get_required_hip3_dexes(): + for dex in self._get_configured_hip3_dexes(): try: positions.extend(self._api.fetch_positions(params={"dex": dex})) except Exception as e: From ce8732b0cd04c1a3129a8e2b7af044c2c7912c27 Mon Sep 17 00:00:00 2001 From: Ingo Date: Sun, 21 Dec 2025 07:56:18 +0100 Subject: [PATCH 13/34] modified tests for hip3 assets + add new tests for hip3 assets --- tests/exchange/test_hyperliquid.py | 445 ++++++++++++++++++++++++++++- 1 file changed, 433 insertions(+), 12 deletions(-) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index c2bff933900..8b159537ed1 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -11,10 +11,22 @@ def test_hyperliquid_dry_run_liquidation_price(default_conf, 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}}}, + "BTC/USDC:USDC": {"limits": {"leverage": {"max": 50}}, "info": {}}, + "ETH/USDC:USDC": {"limits": {"leverage": {"max": 50}}, "info": {}}, + "SOL/USDC:USDC": {"limits": {"leverage": {"max": 20}}, "info": {}}, + "DOGE/USDC:USDC": {"limits": {"leverage": {"max": 20}}, "info": {}}, + "XYZ-AAPL/USDC:USDC": { + "limits": {"leverage": {"max": 10}}, + "info": {"hip3": True, "dex": "xyz"}, + }, + "XYZ-TSLA/USDC:USDC": { + "limits": {"leverage": {"max": 10}}, + "info": {"hip3": True, "dex": "xyz"}, + }, + "XYZ-GOOGL/USDC:USDC": { + "limits": {"leverage": {"max": 10}}, + "info": {"hip3": True, "dex": "xyz"}, + }, } positions = [ { @@ -278,12 +290,86 @@ 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-AAPL/USDC:USDC", + "entryPrice": 280.0, + "side": "long", + "contracts": 0.5, + "collateral": 14.0, + "leverage": 10.0, + "liquidationPrice": 265.2631578947368, + }, + { + "symbol": "XYZ-AAPL/USDC:USDC", + "entryPrice": 260.0, + "side": "short", + "contracts": 0.5, + "collateral": 26.0, + "leverage": 5.0, + "liquidationPrice": 297.1428571428571, + }, + { + "symbol": "XYZ-GOOGL/USDC:USDC", + "entryPrice": 180.0, + "side": "long", + "contracts": 1.0, + "collateral": 60.0, + "leverage": 3.0, + "liquidationPrice": 126.3157894736842, + }, + { + "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, + }, + { + "symbol": "XYZ-TSLA/USDC:USDC", + "entryPrice": 340.0, + "side": "short", + "contracts": 0.9999705882352942, + "collateral": 113.33, + "leverage": 3.0, + "liquidationPrice": 431.74603174603175, + }, + { + "symbol": "XYZ-TSLA/USDC:USDC", + "entryPrice": 360.0, + "side": "long", + "contracts": 0.5, + "collateral": 90.0, + "leverage": 2.0, + "liquidationPrice": 189.4736842105263, + }, ] api_mock = MagicMock() default_conf["trading_mode"] = "futures" default_conf["margin_mode"] = margin_mode default_conf["stake_currency"] = "USDC" + # Configure HIP-3 DEXes + default_conf["exchange"]["hip3_dexes"] = ["xyz"] api_mock.load_markets = get_mock_coro() api_mock.markets = markets exchange = get_patched_exchange( @@ -331,15 +417,33 @@ 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 @@ -349,23 +453,36 @@ def test_hyperliquid_get_max_leverage(default_conf, mocker): "ETH/USDC:USDC": {"limits": {"leverage": {"max": 50}}}, "SOL/USDC:USDC": {"limits": {"leverage": {"max": 20}}}, "DOGE/USDC:USDC": {"limits": {"leverage": {"max": 20}}}, + "XYZ-TSLA/USDC:USDC": {"limits": {"leverage": {"max": 10}}}, + "XYZ-NVDA/USDC:USDC": {"limits": {"leverage": {"max": 10}}}, + "VNTL-SPACEX/USDH:USDH": {"limits": {"leverage": {"max": 3}}}, + "VNTL-ANTHROPIC/USDH:USDH": {"limits": {"leverage": {"max": 3}}}, } 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)) + # 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 +499,66 @@ 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): default_conf_usdt["dry_run"] = False + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz", "vntl"] api_mock = MagicMock() + + # Mock markets with HIP-3 info + markets = { + "ETH/USDC:USDC": {"info": {}}, + "XYZ-TSLA/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, + "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, + } + api_mock.markets = markets + api_mock.load_markets = get_mock_coro(return_value=markets) + + # Test with normal market api_mock.fetch_order = MagicMock( return_value={ "id": "12345", @@ -432,9 +590,272 @@ def test_hyperliquid_fetch_order(default_conf_usdt, mocker): }, ], ) - exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange="hyperliquid") + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False + ) 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, mocker): + """Test HIP-3 DEX configuration validation.""" + from freqtrade.exceptions import OperationalException + + api_mock = MagicMock() + markets = { + "BTC/USDC:USDC": {"info": {}}, + "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, + "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, + "FLX-TOKEN/USDC:USDC": {"info": {"hip3": True, "dex": "flx"}}, + } + api_mock.load_markets = get_mock_coro(return_value=markets) + api_mock.markets = markets + + # Test 1: Valid single DEX + default_conf["exchange"]["hip3_dexes"] = ["xyz"] + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + assert exchange._get_configured_hip3_dexes() == ["xyz"] + + # Test 2: Valid two DEXes (max allowed) + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl"] + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + assert exchange._get_configured_hip3_dexes() == ["xyz", "vntl"] + + # Test 3: Too many DEXes + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl", "flx"] + with pytest.raises(OperationalException, match="Maximum 2 HIP-3 DEXes allowed"): + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + exchange.validate_config(default_conf) + + # Test 4: Invalid DEX + default_conf["exchange"]["hip3_dexes"] = ["invalid_dex"] + with pytest.raises(OperationalException, match="Invalid HIP-3 DEXes configured"): + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + exchange.validate_config(default_conf) + + # Test 5: Mix of valid and invalid DEX + default_conf["exchange"]["hip3_dexes"] = ["xyz", "invalid_dex"] + with pytest.raises(OperationalException, match="Invalid HIP-3 DEXes configured"): + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + exchange.validate_config(default_conf) + + +def test_hyperliquid_get_balances_hip3(default_conf, mocker): + """Test balance fetching from HIP-3 DEXes.""" + api_mock = MagicMock() + markets = { + "BTC/USDC:USDC": {"info": {}}, + "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, + "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, + } + api_mock.load_markets = get_mock_coro() + api_mock.markets = markets + + # 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 + 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"] + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + + 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 == 3 + + +def test_hyperliquid_fetch_positions_hip3(default_conf, mocker): + """Test position fetching from HIP-3 DEXes.""" + api_mock = MagicMock() + markets = { + "BTC/USDC:USDC": {"info": {}}, + "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, + "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, + } + api_mock.load_markets = get_mock_coro(return_value=markets) + api_mock.markets = markets + + # 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 + return default_positions + + api_mock.fetch_positions = MagicMock(side_effect=fetch_positions_side_effect) + + default_conf["trading_mode"] = "futures" + default_conf["margin_mode"] = "isolated" + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl"] + + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + + positions = exchange.fetch_positions() + + # Should have HIP-3 positions (default position not included in test mock) + assert len(positions) == 2 + 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 (only HIP-3 DEXes, super() returns empty list in test) + assert api_mock.fetch_positions.call_count == 2 # xyz + vntl + + +def test_hyperliquid_market_is_tradable(default_conf, mocker): + """Test market_is_tradable filters HIP-3 markets correctly.""" + api_mock = MagicMock() + markets = { + "BTC/USDC:USDC": {"info": {}, "active": True}, + "ETH/USDC:USDC": {"info": {}, "active": True}, + "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}, "active": True}, + "XYZ-TSLA/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}, "active": True}, + "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}, "active": True}, + "FLX-TOKEN/USDC:USDC": {"info": {"hip3": True, "dex": "flx"}, "active": True}, + } + api_mock.load_markets = get_mock_coro(return_value=markets) + api_mock.markets = markets + + # Test 1: No HIP-3 DEXes configured - only default markets tradable + default_conf["exchange"]["hip3_dexes"] = [] + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + + assert exchange.market_is_tradable(markets["BTC/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["ETH/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["XYZ-AAPL/USDC:USDC"]) is False + assert exchange.market_is_tradable(markets["XYZ-TSLA/USDC:USDC"]) is False + assert exchange.market_is_tradable(markets["VNTL-SPACEX/USDH:USDH"]) is False + assert exchange.market_is_tradable(markets["FLX-TOKEN/USDC:USDC"]) is False + + # Test 2: Only 'xyz' configured - default + xyz markets tradable + default_conf["exchange"]["hip3_dexes"] = ["xyz"] + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + + assert exchange.market_is_tradable(markets["BTC/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["ETH/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["XYZ-AAPL/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["XYZ-TSLA/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["VNTL-SPACEX/USDH:USDH"]) is False + assert exchange.market_is_tradable(markets["FLX-TOKEN/USDC:USDC"]) is False + + # Test 3: 'xyz' and 'vntl' configured - default + xyz + vntl markets tradable + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl"] + exchange = get_patched_exchange( + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + ) + + assert exchange.market_is_tradable(markets["BTC/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["ETH/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["XYZ-AAPL/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["XYZ-TSLA/USDC:USDC"]) is True + assert exchange.market_is_tradable(markets["VNTL-SPACEX/USDH:USDH"]) is True + assert exchange.market_is_tradable(markets["FLX-TOKEN/USDC:USDC"]) is False From bcd033bd0dfddfbef24d4e311a6e2e7a935d72d1 Mon Sep 17 00:00:00 2001 From: Ingo Date: Sun, 21 Dec 2025 19:55:35 +0100 Subject: [PATCH 14/34] remove HIP3 dex quantity restrictions --- docs/exchanges.md | 30 +++++++++++++++++++++++++ freqtrade/exchange/hyperliquid.py | 11 +--------- tests/exchange/test_hyperliquid.py | 35 ++++++++++-------------------- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/docs/exchanges.md b/docs/exchanges.md index 358242fe931..9c8f3b42682 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 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. + +!!! 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 makes additional Balance and Position API queries. +* **Increased Latency**: Each DEX adds approximately 0.7 seconds of delay due to these calls. +* **Rate Limit Pressure**: Additional API calls contribute to Hyperliquid's strict rate limits. With multiple DEXes, you may hit rate limits faster. + +**Recommendation**: Only add HIP-3 DEXes that you actively trade on. Monitor your logs for rate limit warnings. + +!!! Note + HIP-3 DEXes share the same wallet and free amount of colleteral 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/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 170bc400a01..ceb13d92c33 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -66,16 +66,7 @@ def validate_config(self, config: dict) -> None: super().validate_config(config) configured = self._get_configured_hip3_dexes() - if not configured: - return - - if len(configured) > 2: - raise OperationalException( - f"Maximum 2 HIP-3 DEXes allowed, but {len(configured)} configured: {configured}. " - f"Remove extra DEXes from your 'hip3_dexes' configuration!" - ) - - if not self.markets: + if not configured or not self.markets: return available = { diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index 8b159537ed1..c805342dce8 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -674,8 +674,6 @@ def test_hyperliquid_hip3_config_validation(default_conf, mocker): markets = { "BTC/USDC:USDC": {"info": {}}, "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, - "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, - "FLX-TOKEN/USDC:USDC": {"info": {"hip3": True, "dex": "flx"}}, } api_mock.load_markets = get_mock_coro(return_value=markets) api_mock.markets = markets @@ -687,22 +685,7 @@ def test_hyperliquid_hip3_config_validation(default_conf, mocker): ) assert exchange._get_configured_hip3_dexes() == ["xyz"] - # Test 2: Valid two DEXes (max allowed) - default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl"] - exchange = get_patched_exchange( - mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False - ) - assert exchange._get_configured_hip3_dexes() == ["xyz", "vntl"] - - # Test 3: Too many DEXes - default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl", "flx"] - with pytest.raises(OperationalException, match="Maximum 2 HIP-3 DEXes allowed"): - exchange = get_patched_exchange( - mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False - ) - exchange.validate_config(default_conf) - - # Test 4: Invalid DEX + # Test 2: Invalid DEX default_conf["exchange"]["hip3_dexes"] = ["invalid_dex"] with pytest.raises(OperationalException, match="Invalid HIP-3 DEXes configured"): exchange = get_patched_exchange( @@ -710,7 +693,7 @@ def test_hyperliquid_hip3_config_validation(default_conf, mocker): ) exchange.validate_config(default_conf) - # Test 5: Mix of valid and invalid DEX + # Test 3: Mix of valid and invalid DEX default_conf["exchange"]["hip3_dexes"] = ["xyz", "invalid_dex"] with pytest.raises(OperationalException, match="Invalid HIP-3 DEXes configured"): exchange = get_patched_exchange( @@ -796,15 +779,21 @@ def fetch_positions_side_effect(symbols=None, params=None): mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False ) + # Mock super().fetch_positions() to return default positions + mocker.patch( + "freqtrade.exchange.exchange.Exchange.fetch_positions", return_value=default_positions + ) + positions = exchange.fetch_positions() - # Should have HIP-3 positions (default position not included in test mock) - assert len(positions) == 2 + # 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 (only HIP-3 DEXes, super() returns empty list in test) - assert api_mock.fetch_positions.call_count == 2 # xyz + vntl + # Verify API calls (xyz + vntl, default is mocked separately) + assert api_mock.fetch_positions.call_count == 2 def test_hyperliquid_market_is_tradable(default_conf, mocker): From 45b9c8c53f51a1b1f6b9f3192661aac394563f75 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 16:46:36 +0100 Subject: [PATCH 15/34] chore: use parent methods for proper error handling --- freqtrade/exchange/exchange.py | 12 +++++++----- freqtrade/exchange/hyperliquid.py | 25 +++++++++++++++---------- freqtrade/exchange/kraken.py | 2 +- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 9d6807fa24a..2c26df99308 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -1826,16 +1826,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 @@ -1847,7 +1847,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. @@ -1859,7 +1861,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 ceb13d92c33..72152df36f8 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -9,7 +9,7 @@ from freqtrade.enums import MarginMode, TradingMode from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exchange import Exchange -from freqtrade.exchange.exchange_types import CcxtOrder, CcxtPosition, FtHas +from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, CcxtPosition, FtHas from freqtrade.util.datetime_helpers import dt_from_ts @@ -99,13 +99,13 @@ def market_is_tradable(self, market: dict[str, Any]) -> bool: return True - def get_balances(self) -> dict: + def get_balances(self, params: dict | None = None) -> CcxtBalances: """Fetch balances from default DEX and HIP-3 DEXes needed by tradable pairs.""" balances = super().get_balances() - - for dex in self._get_configured_hip3_dexes(): + dexes = self._get_configured_hip3_dexes() + for dex in dexes: try: - dex_balance = self._api.fetch_balance({"dex": dex}) + 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"]: @@ -121,18 +121,23 @@ def get_balances(self) -> dict: 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) -> list[CcxtPosition]: + 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) - - for dex in self._get_configured_hip3_dexes(): + dexes = self._get_configured_hip3_dexes() + for dex in dexes: try: - positions.extend(self._api.fetch_positions(params={"dex": dex})) + 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: 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 {} From dc4adccaaffbddb242aa01d775e8eeba9b34d99c Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 16:48:53 +0100 Subject: [PATCH 16/34] feat: don't limit pairs in "list-pairs" mode --- freqtrade/exchange/hyperliquid.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 72152df36f8..d818cb27623 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -7,6 +7,7 @@ from freqtrade.constants import BuySell from freqtrade.enums import MarginMode, TradingMode +from freqtrade.enums.runmode import NON_UTIL_MODES from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exchange import Exchange from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, CcxtPosition, FtHas @@ -89,7 +90,7 @@ def market_is_tradable(self, market: dict[str, Any]) -> bool: super().market_is_tradable(market) market_info = market.get("info", {}) - if market_info.get("hip3"): + if market_info.get("hip3") and self._config["runmode"] in NON_UTIL_MODES: configured = self._get_configured_hip3_dexes() if not configured: return False From e71329c82bd580bab548cbd643ff346fbe4fe6be Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 16:50:21 +0100 Subject: [PATCH 17/34] fix: DEX's are isolated margin only --- freqtrade/exchange/hyperliquid.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index d818cb27623..235c4b12058 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -69,6 +69,12 @@ def validate_config(self, config: dict) -> None: configured = self._get_configured_hip3_dexes() if not configured or not self.markets: return + if configured and self.margin_mode != MarginMode.ISOLATED: + raise OperationalException( + "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") From 61eb3306ddc54fde295e7b58ab21552afb5af685 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 16:54:06 +0100 Subject: [PATCH 18/34] docs: fix documentation formatting and wording --- docs/exchanges.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/exchanges.md b/docs/exchanges.md index 9c8f3b42682..55ca8a5566f 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -430,7 +430,8 @@ The Hyperliquid API does not provide historic data beyond the single call to fet ### HIP-3 DEXes -Hyperliquid supports HIP-3 decentralized exchanges (DEXes), which are independent exchanges built on the Hyperliquid infrastructure. These DEXes operate similarly to the main Hyperliquid exchange but are community-created and managed. +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: @@ -443,20 +444,19 @@ To trade on HIP-3 DEXes with Freqtrade, you need to add them to your configurati } ``` -Replace `"dex_name_1"` and `"dex_name_2"` with the actual names of the HIP-3 DEXes you want to trade on. +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 +!!! Warning "Performance and Rate Limit Impact" + Each HIP-3 DEX you add significantly impacts bot performance and rate limits. -**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. -* **Additional API Calls**: For each HIP-3 DEX configured, Freqtrade makes additional Balance and Position API queries. -* **Increased Latency**: Each DEX adds approximately 0.7 seconds of delay due to these calls. -* **Rate Limit Pressure**: Additional API calls contribute to Hyperliquid's strict rate limits. With multiple DEXes, you may hit rate limits faster. - -**Recommendation**: Only add HIP-3 DEXes that you actively trade on. Monitor your logs for rate limit warnings. + 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 colleteral as your main Hyperliquid account. Trades on different DEXes will affect your overall account balance and margin. + 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 From 2708343a2d4f7a1d8214e68b9d868308c23b281d Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 17:07:35 +0100 Subject: [PATCH 19/34] fix: only validate HIP3 in futures mode --- freqtrade/exchange/hyperliquid.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 235c4b12058..e35137f273d 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -65,7 +65,8 @@ def _get_configured_hip3_dexes(self) -> list[str]: def validate_config(self, config: dict) -> None: """Validate HIP-3 configuration at bot startup.""" super().validate_config(config) - + if self.trading_mode != TradingMode.FUTURES: + return configured = self._get_configured_hip3_dexes() if not configured or not self.markets: return From 1dde1ad0ed5bf3c790eda384319006133e766282 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 17:10:28 +0100 Subject: [PATCH 20/34] test: fix and improve hip3 config validation test --- tests/exchange/test_hyperliquid.py | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index c805342dce8..d8656a71cc0 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -666,7 +666,7 @@ def test_hyperliquid_fetch_order(default_conf_usdt, mocker): assert gtfo_mock.call_count == 1 -def test_hyperliquid_hip3_config_validation(default_conf, mocker): +def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): """Test HIP-3 DEX configuration validation.""" from freqtrade.exceptions import OperationalException @@ -679,27 +679,36 @@ def test_hyperliquid_hip3_config_validation(default_conf, mocker): api_mock.markets = markets # Test 1: Valid single DEX - default_conf["exchange"]["hip3_dexes"] = ["xyz"] + 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, api_mock, exchange="hyperliquid", mock_markets=False + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False ) assert exchange._get_configured_hip3_dexes() == ["xyz"] # Test 2: Invalid DEX - default_conf["exchange"]["hip3_dexes"] = ["invalid_dex"] + default_conf_usdt["exchange"]["hip3_dexes"] = ["invalid_dex"] with pytest.raises(OperationalException, match="Invalid HIP-3 DEXes configured"): exchange = get_patched_exchange( - mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False ) - exchange.validate_config(default_conf) + exchange.validate_config(default_conf_usdt) # Test 3: Mix of valid and invalid DEX - default_conf["exchange"]["hip3_dexes"] = ["xyz", "invalid_dex"] + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz", "invalid_dex"] with pytest.raises(OperationalException, match="Invalid HIP-3 DEXes configured"): exchange = get_patched_exchange( - mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False + ) + exchange.validate_config(default_conf_usdt) + + default_conf_usdt["margin_mode"] = "cross" + with pytest.raises(OperationalException, match="HIP-3 DEXes require 'isolated' margin mode"): + exchange = get_patched_exchange( + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False ) - exchange.validate_config(default_conf) + exchange.validate_config(default_conf_usdt) def test_hyperliquid_get_balances_hip3(default_conf, mocker): From 00de8077f7525cb9438d2cc328c3da8677293c8a Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 17:18:15 +0100 Subject: [PATCH 21/34] test: fix hyperliquid positions test --- tests/exchange/test_hyperliquid.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index d8656a71cc0..b827c1772af 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -778,7 +778,7 @@ def fetch_positions_side_effect(symbols=None, params=None): return vntl_positions return default_positions - api_mock.fetch_positions = MagicMock(side_effect=fetch_positions_side_effect) + positions_mock = MagicMock(side_effect=fetch_positions_side_effect) default_conf["trading_mode"] = "futures" default_conf["margin_mode"] = "isolated" @@ -789,9 +789,7 @@ def fetch_positions_side_effect(symbols=None, params=None): ) # Mock super().fetch_positions() to return default positions - mocker.patch( - "freqtrade.exchange.exchange.Exchange.fetch_positions", return_value=default_positions - ) + mocker.patch(f"{EXMS}.fetch_positions", positions_mock) positions = exchange.fetch_positions() @@ -802,7 +800,7 @@ def fetch_positions_side_effect(symbols=None, params=None): assert any(p["symbol"] == "VNTL-SPACEX/USDH:USDH" for p in positions) # Verify API calls (xyz + vntl, default is mocked separately) - assert api_mock.fetch_positions.call_count == 2 + assert positions_mock.call_count == 3 def test_hyperliquid_market_is_tradable(default_conf, mocker): From 414fe81b2772bcfdc2c837615a6979f5a3247860 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 17:18:43 +0100 Subject: [PATCH 22/34] test: reduce test position data --- tests/exchange/test_hyperliquid.py | 47 ------------------------------ 1 file changed, 47 deletions(-) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index b827c1772af..05de07bcd24 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -299,33 +299,6 @@ def test_hyperliquid_dry_run_liquidation_price(default_conf, mocker, margin_mode "leverage": 5.0, "liquidationPrice": 210.5263157894737, }, - { - "symbol": "XYZ-AAPL/USDC:USDC", - "entryPrice": 280.0, - "side": "long", - "contracts": 0.5, - "collateral": 14.0, - "leverage": 10.0, - "liquidationPrice": 265.2631578947368, - }, - { - "symbol": "XYZ-AAPL/USDC:USDC", - "entryPrice": 260.0, - "side": "short", - "contracts": 0.5, - "collateral": 26.0, - "leverage": 5.0, - "liquidationPrice": 297.1428571428571, - }, - { - "symbol": "XYZ-GOOGL/USDC:USDC", - "entryPrice": 180.0, - "side": "long", - "contracts": 1.0, - "collateral": 60.0, - "leverage": 3.0, - "liquidationPrice": 126.3157894736842, - }, { "symbol": "XYZ-GOOGL/USDC:USDC", "entryPrice": 190.0, @@ -344,32 +317,12 @@ def test_hyperliquid_dry_run_liquidation_price(default_conf, mocker, margin_mode "leverage": 7.0, "liquidationPrice": 315.7894736842105, }, - { - "symbol": "XYZ-TSLA/USDC:USDC", - "entryPrice": 340.0, - "side": "short", - "contracts": 0.9999705882352942, - "collateral": 113.33, - "leverage": 3.0, - "liquidationPrice": 431.74603174603175, - }, - { - "symbol": "XYZ-TSLA/USDC:USDC", - "entryPrice": 360.0, - "side": "long", - "contracts": 0.5, - "collateral": 90.0, - "leverage": 2.0, - "liquidationPrice": 189.4736842105263, - }, ] api_mock = MagicMock() default_conf["trading_mode"] = "futures" default_conf["margin_mode"] = margin_mode default_conf["stake_currency"] = "USDC" - # Configure HIP-3 DEXes - default_conf["exchange"]["hip3_dexes"] = ["xyz"] api_mock.load_markets = get_mock_coro() api_mock.markets = markets exchange = get_patched_exchange( From b26c37af621af48637caf6f74e05a52183c9ce45 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 17:33:34 +0100 Subject: [PATCH 23/34] test: test online to ensure HIP3 pairs are in the markets object --- tests/exchange_online/conftest.py | 2 ++ tests/exchange_online/test_ccxt_compat.py | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/exchange_online/conftest.py b/tests/exchange_online/conftest.py index 755e6716c35..9d8e52f9088 100644 --- a/tests/exchange_online/conftest.py +++ b/tests/exchange_online/conftest.py @@ -524,6 +524,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 From e77e901df14967058fd29f7e42bb02244bb3eabd Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 18:25:19 +0100 Subject: [PATCH 24/34] fix: don't ignore regular tradability rules --- freqtrade/exchange/hyperliquid.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index e35137f273d..ed6904921c7 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -94,7 +94,7 @@ def validate_config(self, config: dict) -> None: def market_is_tradable(self, market: dict[str, Any]) -> bool: """Check if market is tradable, including HIP-3 markets.""" - super().market_is_tradable(market) + parent_check = super().market_is_tradable(market) market_info = market.get("info", {}) if market_info.get("hip3") and self._config["runmode"] in NON_UTIL_MODES: @@ -103,9 +103,9 @@ def market_is_tradable(self, market: dict[str, Any]) -> bool: return False market_dex = market_info.get("dex") - return market_dex in configured + return parent_check and market_dex in configured - return True + 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.""" From 9b99c5b3f309c4bd187d91407471416f628c78e0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 20:10:01 +0100 Subject: [PATCH 25/34] chore: Add explanation for get_balances --- freqtrade/exchange/hyperliquid.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index ed6904921c7..1cd6110beee 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -108,7 +108,10 @@ def market_is_tradable(self, market: dict[str, Any]) -> bool: 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.""" + """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: From b39470554584f66ecf5b8d5a68ebee2448e01458 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 20:10:19 +0100 Subject: [PATCH 26/34] fix: improve check tightness in hyperliquid hip3 config --- freqtrade/exchange/hyperliquid.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 1cd6110beee..939dffd0b1c 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -65,11 +65,16 @@ def _get_configured_hip3_dexes(self) -> list[str]: def validate_config(self, config: dict) -> None: """Validate HIP-3 configuration at bot startup.""" super().validate_config(config) - if self.trading_mode != TradingMode.FUTURES: - return configured = self._get_configured_hip3_dexes() if not configured or not self.markets: return + if self.trading_mode != TradingMode.FUTURES: + if configured: + raise OperationalException( + "HIP-3 DEXes are only supported in FUTURES trading mode. " + "Please update your configuration!" + ) + return if configured and self.margin_mode != MarginMode.ISOLATED: raise OperationalException( "HIP-3 DEXes require 'isolated' margin mode. " From b8cf38cb6f58b92b8f79e04d7d526edb0d4c7f2d Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 27 Dec 2025 20:15:19 +0100 Subject: [PATCH 27/34] test: fix a couple hyperliquid tests --- tests/exchange/test_hyperliquid.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index 05de07bcd24..ff54a92a2ae 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -498,6 +498,8 @@ def test_hyperliquid__lev_prep(default_conf, mocker): def test_hyperliquid_fetch_order(default_conf_usdt, mocker): 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() @@ -691,6 +693,8 @@ def fetch_balance_side_effect(params=None): # Test with two HIP-3 DEXes default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl"] + default_conf["trading_mode"] = "futures" + default_conf["margin_mode"] = "isolated" exchange = get_patched_exchange( mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False ) @@ -758,6 +762,8 @@ def fetch_positions_side_effect(symbols=None, params=None): def test_hyperliquid_market_is_tradable(default_conf, mocker): """Test market_is_tradable filters HIP-3 markets correctly.""" + default_conf["trading_mode"] = "futures" + default_conf["margin_mode"] = "isolated" api_mock = MagicMock() markets = { "BTC/USDC:USDC": {"info": {}, "active": True}, @@ -769,6 +775,8 @@ def test_hyperliquid_market_is_tradable(default_conf, mocker): } api_mock.load_markets = get_mock_coro(return_value=markets) api_mock.markets = markets + # 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["exchange"]["hip3_dexes"] = [] From 6e9b60c0b09cb6060aa11bd71992d24e4a1ab499 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 28 Dec 2025 13:24:43 +0100 Subject: [PATCH 28/34] fix: use ConfigurationError instead of OperationalException --- freqtrade/exchange/hyperliquid.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 939dffd0b1c..99632eabb2d 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -8,7 +8,7 @@ from freqtrade.constants import BuySell from freqtrade.enums import MarginMode, TradingMode from freqtrade.enums.runmode import NON_UTIL_MODES -from freqtrade.exceptions import ExchangeError, OperationalException +from freqtrade.exceptions import ConfigurationError, ExchangeError, OperationalException from freqtrade.exchange import Exchange from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, CcxtPosition, FtHas from freqtrade.util.datetime_helpers import dt_from_ts @@ -70,13 +70,13 @@ def validate_config(self, config: dict) -> None: return if self.trading_mode != TradingMode.FUTURES: if configured: - raise OperationalException( + 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 OperationalException( + raise ConfigurationError( "HIP-3 DEXes require 'isolated' margin mode. " f"Current margin mode: '{self.margin_mode.value}'. " "Please update your configuration!" @@ -91,7 +91,7 @@ def validate_config(self, config: dict) -> None: invalid = set(configured) - available if invalid: - raise OperationalException( + raise ConfigurationError( f"Invalid HIP-3 DEXes configured: {sorted(invalid)}. " f"Available DEXes: {sorted(available)}. " f"Check your 'hip3_dexes' configuration!" From de9417042487c3ea717f8ea3d4bdbe28fdec5031 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 28 Dec 2025 13:24:52 +0100 Subject: [PATCH 29/34] test: update and improve validation tests --- tests/exchange/test_hyperliquid.py | 32 +++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index ff54a92a2ae..5286112f393 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -1,8 +1,10 @@ +from copy import deepcopy from datetime import UTC, datetime from unittest.mock import MagicMock, PropertyMock import pytest +from freqtrade.exceptions import ConfigurationError from tests.conftest import EXMS, get_mock_coro, get_patched_exchange @@ -623,7 +625,6 @@ def test_hyperliquid_fetch_order(default_conf_usdt, mocker): def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): """Test HIP-3 DEX configuration validation.""" - from freqtrade.exceptions import OperationalException api_mock = MagicMock() markets = { @@ -633,7 +634,24 @@ def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): api_mock.load_markets = get_mock_coro(return_value=markets) api_mock.markets = markets - # Test 1: Valid single DEX + # 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=False + ) + exchange.validate_config(default_conf_copy) + + # 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=False + ) + # Valid single DEX default_conf_usdt["trading_mode"] = "futures" default_conf_usdt["margin_mode"] = "isolated" default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"] @@ -642,24 +660,24 @@ def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): ) assert exchange._get_configured_hip3_dexes() == ["xyz"] - # Test 2: Invalid DEX + # Invalid DEX default_conf_usdt["exchange"]["hip3_dexes"] = ["invalid_dex"] - with pytest.raises(OperationalException, match="Invalid HIP-3 DEXes configured"): + with pytest.raises(ConfigurationError, match="Invalid HIP-3 DEXes configured"): exchange = get_patched_exchange( mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False ) exchange.validate_config(default_conf_usdt) - # Test 3: Mix of valid and invalid DEX + # Mix of valid and invalid DEX default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz", "invalid_dex"] - with pytest.raises(OperationalException, match="Invalid HIP-3 DEXes configured"): + with pytest.raises(ConfigurationError, match="Invalid HIP-3 DEXes configured"): exchange = get_patched_exchange( mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False ) exchange.validate_config(default_conf_usdt) default_conf_usdt["margin_mode"] = "cross" - with pytest.raises(OperationalException, match="HIP-3 DEXes require 'isolated' margin mode"): + 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=False ) From 511b817e84738d85f40946ccbc65b31cce391401 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 28 Dec 2025 13:28:46 +0100 Subject: [PATCH 30/34] test: test exception case for one dex call --- tests/exchange/test_hyperliquid.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index 5286112f393..5a65e9565c8 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -5,7 +5,7 @@ import pytest from freqtrade.exceptions import ConfigurationError -from tests.conftest import EXMS, get_mock_coro, get_patched_exchange +from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has_re @pytest.mark.parametrize("margin_mode", ["isolated", "cross"]) @@ -684,13 +684,14 @@ def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): exchange.validate_config(default_conf_usdt) -def test_hyperliquid_get_balances_hip3(default_conf, mocker): +def test_hyperliquid_get_balances_hip3(default_conf, mocker, caplog): """Test balance fetching from HIP-3 DEXes.""" api_mock = MagicMock() markets = { "BTC/USDC:USDC": {"info": {}}, "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, + "FLX-TOKEN/USDC:USDC": {"info": {"hip3": True, "dex": "flx"}}, } api_mock.load_markets = get_mock_coro() api_mock.markets = markets @@ -705,12 +706,14 @@ def fetch_balance_side_effect(params=None): 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"] + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl", "flx"] default_conf["trading_mode"] = "futures" default_conf["margin_mode"] = "isolated" exchange = get_patched_exchange( @@ -727,16 +730,18 @@ def fetch_balance_side_effect(params=None): assert balances["USDH"]["used"] == 300 assert balances["USDH"]["total"] == 300 - assert api_mock.fetch_balance.call_count == 3 + 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): +def test_hyperliquid_fetch_positions_hip3(default_conf, mocker, caplog): """Test position fetching from HIP-3 DEXes.""" api_mock = MagicMock() markets = { "BTC/USDC:USDC": {"info": {}}, "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, + "FLX-TOKEN/USDC:USDC": {"info": {"hip3": True, "dex": "flx"}}, } api_mock.load_markets = get_mock_coro(return_value=markets) api_mock.markets = markets @@ -751,13 +756,15 @@ def fetch_positions_side_effect(symbols=None, params=None): 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"] + default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl", "flx"] exchange = get_patched_exchange( mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False @@ -768,6 +775,8 @@ def fetch_positions_side_effect(symbols=None, params=None): 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) @@ -775,7 +784,7 @@ def fetch_positions_side_effect(symbols=None, params=None): 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 == 3 + assert positions_mock.call_count == 4 def test_hyperliquid_market_is_tradable(default_conf, mocker): From 75a36514932300abd85e32145b96c3bddf5af70f Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 30 Dec 2025 13:03:13 +0100 Subject: [PATCH 31/34] test: ensure hyperliquid works without dex configured --- tests/exchange/test_hyperliquid.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index 5a65e9565c8..fbe6cd7650e 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -643,6 +643,10 @@ def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): ) 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=False + ) # Not in futures mode default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"] with pytest.raises( From 0a94a6d3321d1f9102bed80a982814728165d3f1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 30 Dec 2025 20:31:49 +0100 Subject: [PATCH 32/34] test: refactor hyperliquid fake markets into a fixture --- tests/exchange/test_hyperliquid.py | 234 +++++++++++++++++------------ 1 file changed, 140 insertions(+), 94 deletions(-) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index fbe6cd7650e..a8f2a2a288e 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -8,28 +8,118 @@ from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has_re -@pytest.mark.parametrize("margin_mode", ["isolated", "cross"]) -def test_hyperliquid_dry_run_liquidation_price(default_conf, 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 +@pytest.fixture +def markets_hip3(): markets = { - "BTC/USDC:USDC": {"limits": {"leverage": {"max": 50}}, "info": {}}, - "ETH/USDC:USDC": {"limits": {"leverage": {"max": 50}}, "info": {}}, - "SOL/USDC:USDC": {"limits": {"leverage": {"max": 20}}, "info": {}}, - "DOGE/USDC:USDC": {"limits": {"leverage": {"max": 20}}, "info": {}}, + "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, 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 + positions = [ { "symbol": "ETH/USDC:USDC", @@ -326,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 ) @@ -402,17 +492,7 @@ def test_hyperliquid_get_funding_fees(default_conf, mocker): 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}}}, - "XYZ-TSLA/USDC:USDC": {"limits": {"leverage": {"max": 10}}}, - "XYZ-NVDA/USDC:USDC": {"limits": {"leverage": {"max": 10}}}, - "VNTL-SPACEX/USDH:USDH": {"limits": {"leverage": {"max": 3}}}, - "VNTL-ANTHROPIC/USDH:USDH": {"limits": {"leverage": {"max": 3}}}, - } +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 @@ -424,7 +504,7 @@ def test_hyperliquid_get_max_leverage(default_conf, mocker): 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 @@ -498,7 +578,7 @@ def test_hyperliquid__lev_prep(default_conf, mocker): ) -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" @@ -506,15 +586,6 @@ def test_hyperliquid_fetch_order(default_conf_usdt, mocker): api_mock = MagicMock() - # Mock markets with HIP-3 info - markets = { - "ETH/USDC:USDC": {"info": {}}, - "XYZ-TSLA/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, - "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, - } - api_mock.markets = markets - api_mock.load_markets = get_mock_coro(return_value=markets) - # Test with normal market api_mock.fetch_order = MagicMock( return_value={ @@ -547,8 +618,9 @@ def test_hyperliquid_fetch_order(default_conf_usdt, mocker): }, ], ) + mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config") exchange = get_patched_exchange( - mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 ) o = exchange.fetch_order("12345", "ETH/USDC:USDC") # Uses weighted average @@ -623,29 +695,23 @@ def test_hyperliquid_fetch_order(default_conf_usdt, mocker): assert gtfo_mock.call_count == 1 -def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): +def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker, markets_hip3): """Test HIP-3 DEX configuration validation.""" api_mock = MagicMock() - markets = { - "BTC/USDC:USDC": {"info": {}}, - "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, - } - api_mock.load_markets = get_mock_coro(return_value=markets) - api_mock.markets = markets # 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=False + 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=False + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 ) # Not in futures mode default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"] @@ -653,14 +719,14 @@ def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): 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=False + 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=False + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 ) assert exchange._get_configured_hip3_dexes() == ["xyz"] @@ -668,7 +734,7 @@ def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): 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=False + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 ) exchange.validate_config(default_conf_usdt) @@ -676,29 +742,23 @@ def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker): 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=False + 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=False + 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): +def test_hyperliquid_get_balances_hip3(default_conf, mocker, caplog, markets_hip3): """Test balance fetching from HIP-3 DEXes.""" api_mock = MagicMock() - markets = { - "BTC/USDC:USDC": {"info": {}}, - "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, - "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, - "FLX-TOKEN/USDC:USDC": {"info": {"hip3": True, "dex": "flx"}}, - } + api_mock.load_markets = get_mock_coro() - api_mock.markets = markets # Mock balance responses default_balance = {"USDC": {"free": 1000, "used": 0, "total": 1000}} @@ -720,8 +780,9 @@ def fetch_balance_side_effect(params=None): 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=False + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 ) balances = exchange.get_balances() @@ -738,17 +799,9 @@ def fetch_balance_side_effect(params=None): assert log_has_re("Could not fetch balance for HIP-3 DEX.*", caplog) -def test_hyperliquid_fetch_positions_hip3(default_conf, mocker, caplog): +def test_hyperliquid_fetch_positions_hip3(default_conf, mocker, caplog, markets_hip3): """Test position fetching from HIP-3 DEXes.""" api_mock = MagicMock() - markets = { - "BTC/USDC:USDC": {"info": {}}, - "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}}, - "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}}, - "FLX-TOKEN/USDC:USDC": {"info": {"hip3": True, "dex": "flx"}}, - } - api_mock.load_markets = get_mock_coro(return_value=markets) - api_mock.markets = markets # Mock position responses default_positions = [{"symbol": "BTC/USDC:USDC", "contracts": 0.5}] @@ -770,8 +823,9 @@ def fetch_positions_side_effect(symbols=None, params=None): 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=False + mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=markets_hip3 ) # Mock super().fetch_positions() to return default positions @@ -791,21 +845,13 @@ def fetch_positions_side_effect(symbols=None, params=None): assert positions_mock.call_count == 4 -def test_hyperliquid_market_is_tradable(default_conf, mocker): +def test_hyperliquid_market_is_tradable(default_conf, mocker, markets_hip3): """Test market_is_tradable filters HIP-3 markets correctly.""" default_conf["trading_mode"] = "futures" default_conf["margin_mode"] = "isolated" api_mock = MagicMock() - markets = { - "BTC/USDC:USDC": {"info": {}, "active": True}, - "ETH/USDC:USDC": {"info": {}, "active": True}, - "XYZ-AAPL/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}, "active": True}, - "XYZ-TSLA/USDC:USDC": {"info": {"hip3": True, "dex": "xyz"}, "active": True}, - "VNTL-SPACEX/USDH:USDH": {"info": {"hip3": True, "dex": "vntl"}, "active": True}, - "FLX-TOKEN/USDC:USDC": {"info": {"hip3": True, "dex": "flx"}, "active": True}, - } - api_mock.load_markets = get_mock_coro(return_value=markets) - api_mock.markets = markets + 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) @@ -815,12 +861,12 @@ def test_hyperliquid_market_is_tradable(default_conf, mocker): mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False ) - assert exchange.market_is_tradable(markets["BTC/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["ETH/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["XYZ-AAPL/USDC:USDC"]) is False - assert exchange.market_is_tradable(markets["XYZ-TSLA/USDC:USDC"]) is False - assert exchange.market_is_tradable(markets["VNTL-SPACEX/USDH:USDH"]) is False - assert exchange.market_is_tradable(markets["FLX-TOKEN/USDC:USDC"]) is 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["exchange"]["hip3_dexes"] = ["xyz"] @@ -828,12 +874,12 @@ def test_hyperliquid_market_is_tradable(default_conf, mocker): mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False ) - assert exchange.market_is_tradable(markets["BTC/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["ETH/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["XYZ-AAPL/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["XYZ-TSLA/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["VNTL-SPACEX/USDH:USDH"]) is False - assert exchange.market_is_tradable(markets["FLX-TOKEN/USDC:USDC"]) is 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["exchange"]["hip3_dexes"] = ["xyz", "vntl"] @@ -841,9 +887,9 @@ def test_hyperliquid_market_is_tradable(default_conf, mocker): mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False ) - assert exchange.market_is_tradable(markets["BTC/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["ETH/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["XYZ-AAPL/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["XYZ-TSLA/USDC:USDC"]) is True - assert exchange.market_is_tradable(markets["VNTL-SPACEX/USDH:USDH"]) is True - assert exchange.market_is_tradable(markets["FLX-TOKEN/USDC:USDC"]) is 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 True + assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is False From 2a1b3ae7babf005833d88743068bdd0bdc51955a Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 30 Dec 2025 20:37:29 +0100 Subject: [PATCH 33/34] test: improve test to disable different stake currency markets --- tests/exchange/test_hyperliquid.py | 33 ++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/tests/exchange/test_hyperliquid.py b/tests/exchange/test_hyperliquid.py index a8f2a2a288e..70a38f04cab 100644 --- a/tests/exchange/test_hyperliquid.py +++ b/tests/exchange/test_hyperliquid.py @@ -699,6 +699,7 @@ def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker, markets_h """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) @@ -845,10 +846,11 @@ def fetch_positions_side_effect(symbols=None, params=None): assert positions_mock.call_count == 4 -def test_hyperliquid_market_is_tradable(default_conf, mocker, markets_hip3): +def test_hyperliquid_market_is_tradable(default_conf_usdt, mocker, markets_hip3): """Test market_is_tradable filters HIP-3 markets correctly.""" - default_conf["trading_mode"] = "futures" - default_conf["margin_mode"] = "isolated" + 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 @@ -856,9 +858,9 @@ def test_hyperliquid_market_is_tradable(default_conf, mocker, markets_hip3): mocker.patch(f"{EXMS}.market_is_tradable", return_value=True) # Test 1: No HIP-3 DEXes configured - only default markets tradable - default_conf["exchange"]["hip3_dexes"] = [] + default_conf_usdt["exchange"]["hip3_dexes"] = [] exchange = get_patched_exchange( - mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False ) assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True @@ -869,9 +871,9 @@ def test_hyperliquid_market_is_tradable(default_conf, mocker, markets_hip3): assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is False # Test 2: Only 'xyz' configured - default + xyz markets tradable - default_conf["exchange"]["hip3_dexes"] = ["xyz"] + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"] exchange = get_patched_exchange( - mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False ) assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True @@ -882,14 +884,27 @@ def test_hyperliquid_market_is_tradable(default_conf, mocker, markets_hip3): 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["exchange"]["hip3_dexes"] = ["xyz", "vntl"] + default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz", "flx"] exchange = get_patched_exchange( - mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False + 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 From 0f8c6d456e36d0b732883577534cda6630465398 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 30 Dec 2025 20:37:48 +0100 Subject: [PATCH 34/34] fix: only matching quote currencies should be tradable --- freqtrade/exchange/hyperliquid.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 99632eabb2d..4a9e1547be9 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -84,7 +84,11 @@ def validate_config(self, config: dict) -> None: available = { m.get("info", {}).get("dex") - for m in self.markets.values() + 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) @@ -93,7 +97,8 @@ def validate_config(self, config: dict) -> None: if invalid: raise ConfigurationError( f"Invalid HIP-3 DEXes configured: {sorted(invalid)}. " - f"Available DEXes: {sorted(available)}. " + f"Available DEXes matching your stake currency ({self._config['stake_currency']}): " + f"{sorted(available)}. " f"Check your 'hip3_dexes' configuration!" )