From b88db55db3b9a8490fe81574a0b3a4102459059f Mon Sep 17 00:00:00 2001 From: gaardiolor Date: Fri, 25 Oct 2024 17:24:59 +0200 Subject: [PATCH] Initial implementation of hyperliquid. - Spot - Swap (long, short, leverage, stoploss_on_exchange) - dry_run_liquidation_price() --- freqtrade/exchange/exchange.py | 3 + freqtrade/exchange/exchange_types.py | 2 + freqtrade/exchange/hyperliquid.py | 147 +++++++++++++++++++++++++-- requirements.txt | 2 +- 4 files changed, 144 insertions(+), 10 deletions(-) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index f7fb8a9c7..aacfb3163 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -144,6 +144,7 @@ class Exchange: "trades_pagination": "time", # Possible are "time" or "id" "trades_pagination_arg": "since", "trades_has_history": False, + "create_order_has_all_data": True, # Set to False if create_order doesn't return all data "l2_limit_range": None, "l2_limit_range_required": True, # Allow Empty L2 limit (kucoin) "mark_ohlcv_price": "mark", @@ -1274,6 +1275,8 @@ class Exchange: rate_for_order, params, ) + if not self._ft_has.get("create_order_has_all_data"): + order = self._api.fetch_order(order['id'], pair) if order.get("status") is None: # Map empty status to open. order["status"] = "open" diff --git a/freqtrade/exchange/exchange_types.py b/freqtrade/exchange/exchange_types.py index e9c58ec38..6bf5cb323 100644 --- a/freqtrade/exchange/exchange_types.py +++ b/freqtrade/exchange/exchange_types.py @@ -34,6 +34,8 @@ class FtHas(TypedDict, total=False): trades_pagination_arg: str trades_has_history: bool trades_pagination_overlap: bool + # Create order + create_order_has_all_data: bool # Orderbook l2_limit_range: Optional[list[int]] l2_limit_range_required: bool diff --git a/freqtrade/exchange/hyperliquid.py b/freqtrade/exchange/hyperliquid.py index 144edbf3a..2a25c73f6 100644 --- a/freqtrade/exchange/hyperliquid.py +++ b/freqtrade/exchange/hyperliquid.py @@ -1,10 +1,14 @@ """Hyperliquid exchange subclass""" import logging +from typing import Optional -from freqtrade.enums import TradingMode from freqtrade.exchange import Exchange -from freqtrade.exchange.exchange_types import FtHas +from typing import List, Tuple, Dict +from freqtrade.enums import MarginMode, TradingMode, CandleType +from freqtrade.exceptions import OperationalException, ExchangeError +from freqtrade.constants import BuySell +from datetime import datetime logger = logging.getLogger(__name__) @@ -15,21 +19,146 @@ class Hyperliquid(Exchange): Contains adjustments needed for Freqtrade to work with this exchange. """ - _ft_has: FtHas = { - # Only the most recent 5000 candles are available according to the - # exchange's API documentation. + _ft_has: Dict = { "ohlcv_has_history": False, "ohlcv_candle_limit": 5000, - "trades_has_history": False, # Trades endpoint doesn't seem available. + "orderbook_max_entries": 20, + "l2_limit_range": [20], + "trades_has_history": False, + "tickers_have_bid_ask": False, + "stoploss_on_exchange": True, "exchange_has_overrides": {"fetchTrades": False}, + "stoploss_order_types": {"limit": "limit"}, + "funding_fee_timeframe": "1h", + "create_order_has_all_data": False } + _supported_trading_mode_margin_pairs: List[Tuple[TradingMode, MarginMode]] = [ + (TradingMode.FUTURES, MarginMode.ISOLATED) + ] + @property - def _ccxt_config(self) -> dict: - # Parameters to add directly to ccxt sync/async initialization. - # ccxt defaults to swap mode. + def _ccxt_config(self) -> Dict: + # ccxt Hyperliquid defaults to swap config = {} if self.trading_mode == TradingMode.SPOT: config.update({"options": {"defaultType": "spot"}}) config.update(super()._ccxt_config) return config + + def get_max_leverage(self, pair: str, stake_amount: Optional[float]) -> float: + # There are no leverage tiers + if self.trading_mode == TradingMode.FUTURES: + return self.markets[pair]['limits']['leverage']['max'] + else: + return 1.0 + + def ohlcv_candle_limit( + self, timeframe: str, candle_type: CandleType, since_ms: Optional[int] = None + ) -> int: + # Funding rate candles have a different limit + if candle_type in CandleType.FUNDING_RATE: + return 500 + + return super().ohlcv_candle_limit(timeframe, candle_type, since_ms) + + def _lev_prep(self, pair: str, leverage: float, side: BuySell, accept_fail: bool = False): + if self.trading_mode != TradingMode.SPOT: + # Hyperliquid expects leverage to be an int + leverage = int(leverage) + # Hyperliquid needs the parameter leverage. + # Don't use set_leverage(), as this sets margin back to cross + self.set_margin_mode(pair, self.margin_mode, params={"leverage": leverage}) + + def dry_run_liquidation_price( + self, + pair: str, + open_rate: float, # Entry price of position + is_short: bool, + amount: float, + stake_amount: float, + leverage: float, + wallet_balance: float, # Or margin balance + mm_ex_1: float = 0.0, # (Binance) Cross only + upnl_ex_1: float = 0.0, # (Binance) Cross only + ) -> Optional[float]: + """ + Optimized + Docs: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/liquidations + Below can be done in fewer lines of code, but like this it matches the documentation. + + Tested with 196 unique ccxt fetch_positions() position outputs + - Only first output per position where pnl=0.0 + - Compare against returned liquidation price + Positions: 197 Average deviation: 0.00028980% Max deviation: 0.01309453% + Positions info: + {'leverage': {1.0: 23, 2.0: 155, 3.0: 8, 4.0: 7, 5.0: 4}, + 'side': {'long': 133, 'short': 64}, + 'symbol': {'BTC/USDC:USDC': 81, + 'DOGE/USDC:USDC': 20, + 'ETH/USDC:USDC': 53, + 'SOL/USDC:USDC': 43}} + """ + # Defining/renaming variables to match the documentation + isolated_margin = stake_amount + position_size = amount + price = open_rate + position_value = price * position_size + max_leverage = self.markets[pair]['limits']['leverage']['max'] + + # Docs: The maintenance margin is half of the initial margin at max leverage, + # which varies from 3-50x. In other words, the maintenance margin is between 1% + # (for 50x max leverage assets) and 16.7% (for 3x max leverage assets) + # depending on the asset + # The key thing here is 'Half of the initial margin at max leverage'. + # A bit ambiguous, but this interpretation leads to accurate results: + # 1. Start from the position value + # 2. Assume max leverage, calculate the initial margin by deviding the position value + # by the max leverage + # 3. Divide this by 2 + maintenance_margin_required = position_value / max_leverage / 2 + + # Docs: margin_available (isolated) = isolated_margin - maintenance_margin_required + margin_available = isolated_margin - maintenance_margin_required + + # Docs: The maintenance margin is half of the initial margin at max leverage + # The docs don't explicitly specify maintenance leverage, but this works. + # Double because of the statement 'half of the initial margin at max leverage' + maintenance_leverage = max_leverage * 2 + + # Docs: l = 1 / MAINTENANCE_LEVERAGE (Using 'll' to comply with PEP8: E741) + ll = 1 / maintenance_leverage + + # Docs: side = 1 for long and -1 for short + side = -1 if is_short else 1 + + # Docs: liq_price = price - side * margin_available / position_size / (1 - l * side) + liq_price = price - side * margin_available / position_size / (1 - ll * side) + + if self.trading_mode == TradingMode.FUTURES: + return liq_price + else: + raise OperationalException( + "Freqtrade only supports isolated futures for leverage trading" + ) + + def get_funding_fees( + self, pair: str, amount: float, is_short: bool, open_date: datetime + ) -> float: + """ + Fetch funding fees, either from the exchange (live) or calculates them + based on funding rate/mark price history + :param pair: The quote/base pair of the trade + :param is_short: trade direction + :param amount: Trade amount + :param open_date: Open date of the trade + :return: funding fee since open_date + :raises: ExchangeError if something goes wrong. + """ + # Bybit does not provide "applied" funding fees per position. + if self.trading_mode == TradingMode.FUTURES: + try: + return self._fetch_and_calculate_funding_fees(pair, amount, is_short, open_date) + except ExchangeError: + logger.warning(f"Could not update funding fees for {pair}.") + return 0.0 diff --git a/requirements.txt b/requirements.txt index 4f0fc2532..5b1d2defb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ bottleneck==1.4.2 numexpr==2.10.1 pandas-ta==0.3.14b -ccxt==4.4.20 +ccxt==4.4.22 cryptography==42.0.8; platform_machine == 'armv7l' cryptography==43.0.3; platform_machine != 'armv7l' aiohttp==3.10.10