From 670999abdaa20aa00d712d49173ad8d2cd6a797e Mon Sep 17 00:00:00 2001 From: Ingo Date: Wed, 3 Dec 2025 05:30:36 +0100 Subject: [PATCH] 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 212505b72..49e9c11d0 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -9,7 +9,7 @@ from freqtrade.constants import BuySell 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 @@ class Hyperliquid(Exchange): 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