Merge pull request #12584 from igi01/feature/hyperliquid-hip3-support

Hyperliquid HIP3 support
This commit is contained in:
Matthias
2025-12-30 21:03:13 +01:00
committed by GitHub
7 changed files with 646 additions and 43 deletions

View File

@@ -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. The Hyperliquid API does not provide historic data beyond the single call to fetch current data, so downloading data is not possible, as the downloaded data would not constitute proper historic data.
### HIP-3 DEXes
Hyperliquid supports HIP-3 decentralized exchanges (DEXes), which are independent exchanges built on top of the Hyperliquid infrastructure.
These DEXes operate similarly to the main Hyperliquid exchange but are community-created and managed.
To trade on HIP-3 DEXes with Freqtrade, you need to add them to your configuration using the `hip3_dexes` parameter:
```json
"exchange": {
"name": "hyperliquid",
"walletAddress": "your_master_wallet_address",
"privateKey": "your_api_private_key",
"hip3_dexes": ["dex_name_1", "dex_name_2"]
}
```
Replace `"dex_name_1"` and `"dex_name_2"` with the actual names of the HIP-3 DEXes you want to trade on (e.g. `vntl` and `xyz`).
!!! Warning "Performance and Rate Limit Impact"
Each HIP-3 DEX you add significantly impacts bot performance and rate limits.
* **Additional API Calls**: For each HIP-3 DEX configured, Freqtrade needs to make additional API calls.
* **Rate Limit Pressure**: Additional API calls contribute to Hyperliquid's strict rate limits. With multiple DEXes, you may hit rate limits faster, or rather, slow down bot operations due to enforced delays.
Please only add HIP-3 DEXes that you actively trade on. Monitor your logs for rate limit warnings or signs of slowed operations, and adjust your configuration accordingly.
Different HIP-3 DEXes may also use different quote currencies - so make sure to only add DEXes that are compatible with your stake currency to avoid unnecessary delays.
!!! Note
HIP-3 DEXes share the same wallet and free amount of collateral as your main Hyperliquid account. Trades on different DEXes will affect your overall account balance and margin.
## Bitvavo ## Bitvavo
If your account is required to use an operatorId, you can set it in the configuration file as follows: If your account is required to use an operatorId, you can set it in the configuration file as follows:

View File

@@ -1827,16 +1827,16 @@ class Exchange:
return order return order
@retrier @retrier
def get_balances(self) -> CcxtBalances: def get_balances(self, params: dict | None = None) -> CcxtBalances:
try: try:
balances = self._api.fetch_balance() balances = self._api.fetch_balance(params or {})
# Remove additional info from ccxt results # Remove additional info from ccxt results
balances.pop("info", None) balances.pop("info", None)
balances.pop("free", None) balances.pop("free", None)
balances.pop("total", None) balances.pop("total", None)
balances.pop("used", None) balances.pop("used", None)
self._log_exchange_response("fetch_balance", balances) self._log_exchange_response("fetch_balance", balances, add_info=params)
return balances return balances
except ccxt.DDoSProtection as e: except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e raise DDosProtection(e) from e
@@ -1848,7 +1848,9 @@ class Exchange:
raise OperationalException(e) from e raise OperationalException(e) from e
@retrier @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. Fetch positions from the exchange.
If no pair is given, all positions are returned. If no pair is given, all positions are returned.
@@ -1860,7 +1862,7 @@ class Exchange:
symbols = None symbols = None
if pair: if pair:
symbols = [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) self._log_exchange_response("fetch_positions", positions)
return positions return positions
except ccxt.DDoSProtection as e: except ccxt.DDoSProtection as e:

View File

@@ -7,9 +7,10 @@ from typing import Any
from freqtrade.constants import BuySell from freqtrade.constants import BuySell
from freqtrade.enums import MarginMode, TradingMode from freqtrade.enums import MarginMode, TradingMode
from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.enums.runmode import NON_UTIL_MODES
from freqtrade.exceptions import ConfigurationError, ExchangeError, OperationalException
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
from freqtrade.exchange.exchange_types import CcxtOrder, FtHas from freqtrade.exchange.exchange_types import CcxtBalances, CcxtOrder, CcxtPosition, FtHas
from freqtrade.util.datetime_helpers import dt_from_ts from freqtrade.util.datetime_helpers import dt_from_ts
@@ -57,12 +58,108 @@ class Hyperliquid(Exchange):
config.update(super()._ccxt_config) config.update(super()._ccxt_config)
return config return config
def _get_configured_hip3_dexes(self) -> list[str]:
"""Get list of configured HIP-3 DEXes."""
return self._config.get("exchange", {}).get("hip3_dexes", [])
def validate_config(self, config: dict) -> None:
"""Validate HIP-3 configuration at bot startup."""
super().validate_config(config)
configured = self._get_configured_hip3_dexes()
if not configured or not self.markets:
return
if self.trading_mode != TradingMode.FUTURES:
if configured:
raise ConfigurationError(
"HIP-3 DEXes are only supported in FUTURES trading mode. "
"Please update your configuration!"
)
return
if configured and self.margin_mode != MarginMode.ISOLATED:
raise ConfigurationError(
"HIP-3 DEXes require 'isolated' margin mode. "
f"Current margin mode: '{self.margin_mode.value}'. "
"Please update your configuration!"
)
available = {
m.get("info", {}).get("dex")
for m in self.get_markets(
quote_currencies=[self._config["stake_currency"]],
tradable_only=True,
active_only=True,
).values()
if m.get("info", {}).get("hip3")
}
available.discard(None)
invalid = set(configured) - available
if invalid:
raise ConfigurationError(
f"Invalid HIP-3 DEXes configured: {sorted(invalid)}. "
f"Available DEXes matching your stake currency ({self._config['stake_currency']}): "
f"{sorted(available)}. "
f"Check your 'hip3_dexes' configuration!"
)
def market_is_tradable(self, market: dict[str, Any]) -> bool: def market_is_tradable(self, market: dict[str, Any]) -> bool:
"""Check if market is tradable, including HIP-3 markets."""
parent_check = super().market_is_tradable(market) parent_check = super().market_is_tradable(market)
# Exclude hip3 markets for now - which have the format XYZ:GOOGL/USDT:USDT - market_info = market.get("info", {})
# and XYZ:GOOGL as base if market_info.get("hip3") and self._config["runmode"] in NON_UTIL_MODES:
return parent_check and ":" not in market["base"] configured = self._get_configured_hip3_dexes()
if not configured:
return False
market_dex = market_info.get("dex")
return parent_check and market_dex in configured
return parent_check
def get_balances(self, params: dict | None = None) -> CcxtBalances:
"""Fetch balances from default DEX and HIP-3 DEXes needed by tradable pairs.
This override is not absolutely necessary and is only there for correct used / total values
which are however not used by Freqtrade in futures mode at the moment.
"""
balances = super().get_balances()
dexes = self._get_configured_hip3_dexes()
for dex in dexes:
try:
dex_balance = super().get_balances(params={"dex": dex})
for currency, amount_info in dex_balance.items():
if currency in ["info", "free", "used", "total", "datetime", "timestamp"]:
continue
if currency not in balances:
balances[currency] = amount_info
else:
balances[currency]["free"] += amount_info["free"]
balances[currency]["used"] += amount_info["used"]
balances[currency]["total"] += amount_info["total"]
except Exception as e:
logger.error(f"Could not fetch balance for HIP-3 DEX '{dex}': {e}")
if dexes:
self._log_exchange_response("fetch_balance", balances, add_info="combined")
return balances
def fetch_positions(
self, pair: str | None = None, params: dict | None = None
) -> list[CcxtPosition]:
"""Fetch positions from default DEX and HIP-3 DEXes needed by tradable pairs."""
positions = super().fetch_positions(pair)
dexes = self._get_configured_hip3_dexes()
for dex in dexes:
try:
positions.extend(super().fetch_positions(pair, params={"dex": dex}))
except Exception as e:
logger.error(f"Could not fetch positions from HIP-3 DEX '{dex}': {e}")
if dexes:
self._log_exchange_response("fetch_positions", positions, add_info="combined")
return positions
def get_max_leverage(self, pair: str, stake_amount: float | None) -> float: def get_max_leverage(self, pair: str, stake_amount: float | None) -> float:
# There are no leverage tiers # There are no leverage tiers

View File

@@ -70,7 +70,7 @@ class Kraken(Exchange):
return consolidated return consolidated
@retrier @retrier
def get_balances(self) -> CcxtBalances: def get_balances(self, params: dict | None = None) -> CcxtBalances:
if self._config["dry_run"]: if self._config["dry_run"]:
return {} return {}

View File

@@ -1,21 +1,125 @@
from copy import deepcopy
from datetime import UTC, datetime from datetime import UTC, datetime
from unittest.mock import MagicMock, PropertyMock from unittest.mock import MagicMock, PropertyMock
import pytest import pytest
from tests.conftest import EXMS, get_mock_coro, get_patched_exchange from freqtrade.exceptions import ConfigurationError
from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has_re
@pytest.fixture
def markets_hip3():
markets = {
"BTC/USDC:USDC": {
"quote": "USDC",
"base": "BTC",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 50}},
"info": {},
},
"ETH/USDC:USDC": {
"quote": "USDC",
"base": "ETH",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 50}},
"info": {},
},
"SOL/USDC:USDC": {
"quote": "USDC",
"base": "SOL",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 20}},
"info": {},
},
"DOGE/USDC:USDC": {
"quote": "USDC",
"base": "DOGE",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 20}},
"info": {},
},
"XYZ-AAPL/USDC:USDC": {
"quote": "USDC",
"base": "XYZ-AAPL",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 10}},
"info": {"hip3": True, "dex": "xyz"},
},
"XYZ-TSLA/USDC:USDC": {
"quote": "USDC",
"base": "XYZ-TSLA",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 10}},
"info": {"hip3": True, "dex": "xyz"},
},
"XYZ-GOOGL/USDC:USDC": {
"quote": "USDC",
"base": "XYZ-GOOGL",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 10}},
"info": {"hip3": True, "dex": "xyz"},
},
"XYZ-NVDA/USDC:USDC": {
"quote": "USDC",
"base": "XYZ-NVDA",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 10}},
"info": {"hip3": True, "dex": "xyz"},
},
"VNTL-SPACEX/USDH:USDH": {
"quote": "USDH",
"base": "VNTL-SPACEX",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 3}},
"info": {"hip3": True, "dex": "vntl"},
},
"VNTL-ANTHROPIC/USDH:USDH": {
"quote": "USDH",
"base": "VNTL-ANTHROPIC",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 3}},
"info": {"hip3": True, "dex": "vntl"},
},
"FLX-TOKEN/USDC:USDC": {
"quote": "USDC",
"base": "FLX-TOKEN",
"type": "swap",
"swap": True,
"linear": True,
"limits": {"leverage": {"max": 3}},
"info": {"hip3": True, "dex": "flx"},
},
}
return markets
@pytest.mark.parametrize("margin_mode", ["isolated", "cross"]) @pytest.mark.parametrize("margin_mode", ["isolated", "cross"])
def test_hyperliquid_dry_run_liquidation_price(default_conf, mocker, margin_mode): def test_hyperliquid_dry_run_liquidation_price(default_conf, markets_hip3, mocker, margin_mode):
# test if liq price calculated by dry_run_liquidation_price() is close to ccxt liq price # 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 # testing different pairs with large/small prices, different leverages, long, short
markets = {
"BTC/USDC:USDC": {"limits": {"leverage": {"max": 50}}},
"ETH/USDC:USDC": {"limits": {"leverage": {"max": 50}}},
"SOL/USDC:USDC": {"limits": {"leverage": {"max": 20}}},
"DOGE/USDC:USDC": {"limits": {"leverage": {"max": 20}}},
}
positions = [ positions = [
{ {
"symbol": "ETH/USDC:USDC", "symbol": "ETH/USDC:USDC",
@@ -278,6 +382,33 @@ def test_hyperliquid_dry_run_liquidation_price(default_conf, mocker, margin_mode
"leverage": 3.0, "leverage": 3.0,
"liquidationPrice": 45236.52992613, "liquidationPrice": 45236.52992613,
}, },
{
"symbol": "XYZ-AAPL/USDC:USDC",
"entryPrice": 250.0,
"side": "long",
"contracts": 0.5,
"collateral": 25.0,
"leverage": 5.0,
"liquidationPrice": 210.5263157894737,
},
{
"symbol": "XYZ-GOOGL/USDC:USDC",
"entryPrice": 190.0,
"side": "short",
"contracts": 0.5,
"collateral": 9.5,
"leverage": 10.0,
"liquidationPrice": 199.04761904761904,
},
{
"symbol": "XYZ-TSLA/USDC:USDC",
"entryPrice": 350.0,
"side": "long",
"contracts": 1.0,
"collateral": 50.0,
"leverage": 7.0,
"liquidationPrice": 315.7894736842105,
},
] ]
api_mock = MagicMock() api_mock = MagicMock()
@@ -285,7 +416,7 @@ def test_hyperliquid_dry_run_liquidation_price(default_conf, mocker, margin_mode
default_conf["margin_mode"] = margin_mode default_conf["margin_mode"] = margin_mode
default_conf["stake_currency"] = "USDC" default_conf["stake_currency"] = "USDC"
api_mock.load_markets = get_mock_coro() api_mock.load_markets = get_mock_coro()
api_mock.markets = markets api_mock.markets = markets_hip3
exchange = get_patched_exchange( exchange = get_patched_exchange(
mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=False
) )
@@ -331,41 +462,62 @@ def test_hyperliquid_get_funding_fees(default_conf, mocker):
now = datetime.now(UTC) now = datetime.now(UTC)
exchange = get_patched_exchange(mocker, default_conf, exchange="hyperliquid") exchange = get_patched_exchange(mocker, default_conf, exchange="hyperliquid")
exchange._fetch_and_calculate_funding_fees = MagicMock() exchange._fetch_and_calculate_funding_fees = MagicMock()
# Spot mode - no funding fees
exchange.get_funding_fees("BTC/USDC:USDC", 1, False, now) exchange.get_funding_fees("BTC/USDC:USDC", 1, False, now)
assert exchange._fetch_and_calculate_funding_fees.call_count == 0 assert exchange._fetch_and_calculate_funding_fees.call_count == 0
default_conf["trading_mode"] = "futures" default_conf["trading_mode"] = "futures"
default_conf["margin_mode"] = "isolated" 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 = get_patched_exchange(mocker, default_conf, exchange="hyperliquid")
exchange._fetch_and_calculate_funding_fees = MagicMock() exchange._fetch_and_calculate_funding_fees = MagicMock()
exchange.get_funding_fees("BTC/USDC:USDC", 1, False, now)
# 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 assert exchange._fetch_and_calculate_funding_fees.call_count == 1
def test_hyperliquid_get_max_leverage(default_conf, mocker): def test_hyperliquid_get_max_leverage(default_conf, mocker, markets_hip3):
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}}},
}
exchange = get_patched_exchange(mocker, default_conf, exchange="hyperliquid") exchange = get_patched_exchange(mocker, default_conf, exchange="hyperliquid")
assert exchange.get_max_leverage("BTC/USDC:USDC", 1) == 1.0 assert exchange.get_max_leverage("BTC/USDC:USDC", 1) == 1.0
default_conf["trading_mode"] = "futures" default_conf["trading_mode"] = "futures"
default_conf["margin_mode"] = "isolated" default_conf["margin_mode"] = "isolated"
exchange = get_patched_exchange(mocker, default_conf, exchange="hyperliquid") default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl"]
mocker.patch.multiple(
EXMS,
markets=PropertyMock(return_value=markets),
)
# 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_hip3))
# Normal markets
assert exchange.get_max_leverage("BTC/USDC:USDC", 1) == 50 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("ETH/USDC:USDC", 20) == 50
assert exchange.get_max_leverage("SOL/USDC:USDC", 50) == 20 assert exchange.get_max_leverage("SOL/USDC:USDC", 50) == 20
assert exchange.get_max_leverage("DOGE/USDC:USDC", 3) == 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): def test_hyperliquid__lev_prep(default_conf, mocker):
api_mock = MagicMock() api_mock = MagicMock()
@@ -382,25 +534,59 @@ def test_hyperliquid__lev_prep(default_conf, mocker):
default_conf["trading_mode"] = "futures" default_conf["trading_mode"] = "futures"
default_conf["margin_mode"] = "isolated" 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 = 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 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.set_margin_mode.assert_called_with("isolated", "BTC/USDC:USDC", {"leverage": 3})
api_mock.reset_mock() api_mock.reset_mock()
exchange._lev_prep("BTC/USDC:USDC", 19.99, "sell") exchange._lev_prep("BTC/USDC:USDC", 19.99, "sell")
assert api_mock.set_margin_mode.call_count == 1 assert api_mock.set_margin_mode.call_count == 1
api_mock.set_margin_mode.assert_called_with("isolated", "BTC/USDC:USDC", {"leverage": 19}) 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})
def test_hyperliquid_fetch_order(default_conf_usdt, mocker): 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, markets_hip3):
default_conf_usdt["dry_run"] = False 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() api_mock = MagicMock()
# Test with normal market
api_mock.fetch_order = MagicMock( api_mock.fetch_order = MagicMock(
return_value={ return_value={
"id": "12345", "id": "12345",
@@ -432,9 +618,293 @@ def test_hyperliquid_fetch_order(default_conf_usdt, mocker):
}, },
], ],
) )
exchange = get_patched_exchange(mocker, default_conf_usdt, api_mock, exchange="hyperliquid") mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config")
exchange = get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
o = exchange.fetch_order("12345", "ETH/USDC:USDC") o = exchange.fetch_order("12345", "ETH/USDC:USDC")
# Uses weighted average # Uses weighted average
assert o["average"] == 1500 assert o["average"] == 1500
assert gtfo_mock.call_count == 1 assert gtfo_mock.call_count == 1
# Test with HIP-3 XYZ market
api_mock.fetch_order = MagicMock(
return_value={
"id": "67890",
"symbol": "XYZ-TSLA/USDC:USDC",
"status": "closed",
"filled": 2.5,
"average": None,
"timestamp": 1630000100,
}
)
gtfo_mock.reset_mock()
gtfo_mock.return_value = [
{
"order_id": "67890",
"price": 250,
"amount": 1.5,
"filled": 1.5,
"remaining": 0,
},
{
"order_id": "67890",
"price": 260,
"amount": 1.0,
"filled": 1.0,
"remaining": 0,
},
]
o = exchange.fetch_order("67890", "XYZ-TSLA/USDC:USDC")
# Weighted average: (250*1.5 + 260*1.0) / 2.5 = 254
assert o["average"] == 254
assert gtfo_mock.call_count == 1
# Test with HIP-3 VNTL market
api_mock.fetch_order = MagicMock(
return_value={
"id": "11111",
"symbol": "VNTL-SPACEX/USDH:USDH",
"status": "closed",
"filled": 5.0,
"average": None,
"timestamp": 1630000200,
}
)
gtfo_mock.reset_mock()
gtfo_mock.return_value = [
{
"order_id": "11111",
"price": 100,
"amount": 3.0,
"filled": 3.0,
"remaining": 0,
},
{
"order_id": "11111",
"price": 105,
"amount": 2.0,
"filled": 2.0,
"remaining": 0,
},
]
o = exchange.fetch_order("11111", "VNTL-SPACEX/USDH:USDH")
assert o["average"] == 102
assert gtfo_mock.call_count == 1
def test_hyperliquid_hip3_config_validation(default_conf_usdt, mocker, markets_hip3):
"""Test HIP-3 DEX configuration validation."""
api_mock = MagicMock()
default_conf_usdt["stake_currency"] = "USDC"
# Futures mode, no dex configured
default_conf_copy = deepcopy(default_conf_usdt)
default_conf_copy["trading_mode"] = "futures"
default_conf_copy["margin_mode"] = "isolated"
exchange = get_patched_exchange(
mocker, default_conf_copy, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
exchange.validate_config(default_conf_copy)
# Not in futures mode - no dex configured - no error
get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
# Not in futures mode
default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"]
with pytest.raises(
ConfigurationError, match=r"HIP-3 DEXes are only supported in FUTURES trading mode\."
):
get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
# Valid single DEX
default_conf_usdt["trading_mode"] = "futures"
default_conf_usdt["margin_mode"] = "isolated"
default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"]
exchange = get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
assert exchange._get_configured_hip3_dexes() == ["xyz"]
# Invalid DEX
default_conf_usdt["exchange"]["hip3_dexes"] = ["invalid_dex"]
with pytest.raises(ConfigurationError, match="Invalid HIP-3 DEXes configured"):
exchange = get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
exchange.validate_config(default_conf_usdt)
# Mix of valid and invalid DEX
default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz", "invalid_dex"]
with pytest.raises(ConfigurationError, match="Invalid HIP-3 DEXes configured"):
exchange = get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
exchange.validate_config(default_conf_usdt)
default_conf_usdt["margin_mode"] = "cross"
with pytest.raises(ConfigurationError, match="HIP-3 DEXes require 'isolated' margin mode"):
exchange = get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
exchange.validate_config(default_conf_usdt)
def test_hyperliquid_get_balances_hip3(default_conf, mocker, caplog, markets_hip3):
"""Test balance fetching from HIP-3 DEXes."""
api_mock = MagicMock()
api_mock.load_markets = get_mock_coro()
# Mock balance responses
default_balance = {"USDC": {"free": 1000, "used": 0, "total": 1000}}
xyz_balance = {"USDC": {"free": 0, "used": 600, "total": 600}}
vntl_balance = {"USDH": {"free": 0, "used": 300, "total": 300}}
def fetch_balance_side_effect(params=None):
if params and params.get("dex") == "xyz":
return xyz_balance
elif params and params.get("dex") == "vntl":
return vntl_balance
elif params and params.get("dex") == "flx":
raise Exception("FLX DEX error")
return default_balance
api_mock.fetch_balance = MagicMock(side_effect=fetch_balance_side_effect)
# Test with two HIP-3 DEXes
default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl", "flx"]
default_conf["trading_mode"] = "futures"
default_conf["margin_mode"] = "isolated"
mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config")
exchange = get_patched_exchange(
mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
balances = exchange.get_balances()
# Should have combined balances
assert balances["USDC"]["free"] == 1000
assert balances["USDC"]["used"] == 600
assert balances["USDC"]["total"] == 1600
assert balances["USDH"]["free"] == 0
assert balances["USDH"]["used"] == 300
assert balances["USDH"]["total"] == 300
assert api_mock.fetch_balance.call_count == 4
assert log_has_re("Could not fetch balance for HIP-3 DEX.*", caplog)
def test_hyperliquid_fetch_positions_hip3(default_conf, mocker, caplog, markets_hip3):
"""Test position fetching from HIP-3 DEXes."""
api_mock = MagicMock()
# Mock position responses
default_positions = [{"symbol": "BTC/USDC:USDC", "contracts": 0.5}]
xyz_positions = [{"symbol": "XYZ-AAPL/USDC:USDC", "contracts": 10}]
vntl_positions = [{"symbol": "VNTL-SPACEX/USDH:USDH", "contracts": 5}]
def fetch_positions_side_effect(symbols=None, params=None):
if params and params.get("dex") == "xyz":
return xyz_positions
elif params and params.get("dex") == "vntl":
return vntl_positions
elif params and params.get("dex") == "flx":
raise Exception("FLX DEX error")
return default_positions
positions_mock = MagicMock(side_effect=fetch_positions_side_effect)
default_conf["trading_mode"] = "futures"
default_conf["margin_mode"] = "isolated"
default_conf["exchange"]["hip3_dexes"] = ["xyz", "vntl", "flx"]
mocker.patch("freqtrade.exchange.hyperliquid.Hyperliquid.validate_config")
exchange = get_patched_exchange(
mocker, default_conf, api_mock, exchange="hyperliquid", mock_markets=markets_hip3
)
# Mock super().fetch_positions() to return default positions
mocker.patch(f"{EXMS}.fetch_positions", positions_mock)
positions = exchange.fetch_positions()
assert log_has_re("Could not fetch positions from HIP-3 .*", caplog)
# Should have all positions combined (default + HIP-3)
assert len(positions) == 3
assert any(p["symbol"] == "BTC/USDC:USDC" for p in positions)
assert any(p["symbol"] == "XYZ-AAPL/USDC:USDC" for p in positions)
assert any(p["symbol"] == "VNTL-SPACEX/USDH:USDH" for p in positions)
# Verify API calls (xyz + vntl, default is mocked separately)
assert positions_mock.call_count == 4
def test_hyperliquid_market_is_tradable(default_conf_usdt, mocker, markets_hip3):
"""Test market_is_tradable filters HIP-3 markets correctly."""
default_conf_usdt["stake_currency"] = "USDC"
default_conf_usdt["trading_mode"] = "futures"
default_conf_usdt["margin_mode"] = "isolated"
api_mock = MagicMock()
api_mock.load_markets = get_mock_coro(return_value=markets_hip3)
api_mock.markets = markets_hip3
# Mock parent call - we only want to test hyperliquid specifics here.
mocker.patch(f"{EXMS}.market_is_tradable", return_value=True)
# Test 1: No HIP-3 DEXes configured - only default markets tradable
default_conf_usdt["exchange"]["hip3_dexes"] = []
exchange = get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False
)
assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["ETH/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["XYZ-AAPL/USDC:USDC"]) is False
assert exchange.market_is_tradable(markets_hip3["XYZ-TSLA/USDC:USDC"]) is False
assert exchange.market_is_tradable(markets_hip3["VNTL-SPACEX/USDH:USDH"]) is False
assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is False
# Test 2: Only 'xyz' configured - default + xyz markets tradable
default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz"]
exchange = get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False
)
assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["ETH/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["XYZ-AAPL/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["XYZ-TSLA/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["VNTL-SPACEX/USDH:USDH"]) is False
assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is False
# Test 3: 'xyz' and 'vntl' configured - default + xyz + vntl markets tradable
default_conf_usdt["exchange"]["hip3_dexes"] = ["xyz", "flx"]
exchange = get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False
)
assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["ETH/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["XYZ-AAPL/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["XYZ-TSLA/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["VNTL-SPACEX/USDH:USDH"]) is False
assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is True
# Use USDH stake currency to enable VNTL markets
default_conf_usdt["exchange"]["hip3_dexes"] = ["vntl"]
default_conf_usdt["stake_currency"] = "USDH"
exchange = get_patched_exchange(
mocker, default_conf_usdt, api_mock, exchange="hyperliquid", mock_markets=False
)
assert exchange.market_is_tradable(markets_hip3["BTC/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["ETH/USDC:USDC"]) is True
assert exchange.market_is_tradable(markets_hip3["XYZ-AAPL/USDC:USDC"]) is False
assert exchange.market_is_tradable(markets_hip3["XYZ-TSLA/USDC:USDC"]) is False
assert exchange.market_is_tradable(markets_hip3["VNTL-SPACEX/USDH:USDH"]) is True
assert exchange.market_is_tradable(markets_hip3["FLX-TOKEN/USDC:USDC"]) is False

View File

@@ -523,6 +523,8 @@ EXCHANGES = {
"candle_count": 5000, "candle_count": 5000,
"orderbook_max_entries": 20, "orderbook_max_entries": 20,
"futures_pair": "BTC/USDC:USDC", "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, "hasQuoteVolumeFutures": True,
"leverage_tiers_public": False, "leverage_tiers_public": False,
"leverage_in_spot_market": False, "leverage_in_spot_market": False,

View File

@@ -67,12 +67,14 @@ class TestCCXTExchange:
def test_load_markets_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE): def test_load_markets_futures(self, exchange_futures: EXCHANGE_FIXTURE_TYPE):
exchange, exchangename = exchange_futures exchange, exchangename = exchange_futures
pair = EXCHANGES[exchangename]["pair"] 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 markets = exchange.markets
assert pair in markets for pair in [pair1] + alternative_pairs:
assert isinstance(markets[pair], dict) 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): def test_ccxt_order_parse(self, exchange: EXCHANGE_FIXTURE_TYPE):
exch, exchange_name = exchange exch, exchange_name = exchange