diff --git a/freqtrade/exchange/bibox.py b/freqtrade/exchange/bibox.py index 5574c401a..da1effbfe 100644 --- a/freqtrade/exchange/bibox.py +++ b/freqtrade/exchange/bibox.py @@ -1,6 +1,6 @@ """ Bibox exchange subclass """ import logging -from typing import Dict, List +from typing import Dict from freqtrade.exchange import Exchange @@ -26,5 +26,3 @@ class Bibox(Exchange): config = {"has": {"fetchCurrencies": False}} config.update(super()._ccxt_config) return config - - funding_fee_times: List[int] = [0, 8, 16] # hours of the day diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 93d1605d9..f4aeacae3 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -1,6 +1,7 @@ """ Binance exchange subclass """ import json import logging +from datetime import datetime from pathlib import Path from typing import Dict, List, Optional, Tuple @@ -29,8 +30,6 @@ class Binance(Exchange): "l2_limit_range": [5, 10, 20, 50, 100, 500, 1000], "ccxt_futures_name": "future" } - funding_fee_times: List[int] = [0, 8, 16] # hours of the day - # but the schedule won't check within this timeframe _supported_trading_mode_collateral_pairs: List[Tuple[TradingMode, Collateral]] = [ # TradingMode.SPOT always supported and not required in this list @@ -211,3 +210,11 @@ class Binance(Exchange): f"{arrow.get(since_ms // 1000).isoformat()}.") return await super()._async_get_historic_ohlcv( pair=pair, timeframe=timeframe, since_ms=since_ms, is_new_pair=is_new_pair) + + def funding_fee_cutoff(self, open_date: datetime): + """ + # TODO-lev: Double check that gateio, ftx, and kraken don't also have this + :param open_date: The open date for a trade + :return: The cutoff open time for when a funding fee is charged + """ + return open_date.minute > 0 or (open_date.minute == 0 and open_date.second > 15) diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index adc849cda..a1cd40ac6 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -24,8 +24,6 @@ class Bybit(Exchange): "ccxt_futures_name": "linear" } - funding_fee_times: List[int] = [0, 8, 16] # hours of the day - _supported_trading_mode_collateral_pairs: List[Tuple[TradingMode, Collateral]] = [ # TradingMode.SPOT always supported and not required in this list # TODO-lev: Uncomment once supported diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 5025d2396..fe19230a9 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -7,7 +7,7 @@ import http import inspect import logging from copy import deepcopy -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from math import ceil from typing import Any, Dict, List, Optional, Tuple, Union @@ -69,14 +69,11 @@ class Exchange: "trades_pagination_arg": "since", "l2_limit_range": None, "l2_limit_range_required": True, # Allow Empty L2 limit (kucoin) + "mark_ohlcv_price": "mark", "ccxt_futures_name": "swap" } _ft_has: Dict = {} - # funding_fee_times is currently unused, but should ideally be used to properly - # schedule refresh times - funding_fee_times: List[int] = [] # hours of the day - _supported_trading_mode_collateral_pairs: List[Tuple[TradingMode, Collateral]] = [ # TradingMode.SPOT always supported and not required in this list ] @@ -90,6 +87,7 @@ class Exchange: self._api: ccxt.Exchange = None self._api_async: ccxt_async.Exchange = None self._markets: Dict = {} + self._leverage_brackets: Dict = {} self._config.update(config) @@ -180,7 +178,6 @@ class Exchange: self.markets_refresh_interval: int = exchange_config.get( "markets_refresh_interval", 60) * 60 - self._leverage_brackets: Dict = {} if self.trading_mode != TradingMode.SPOT: self.fill_leverage_brackets() @@ -1619,17 +1616,18 @@ class Exchange: until=until, from_id=from_id)) @retrier - def get_funding_fees_from_exchange(self, pair: str, since: Union[datetime, int]) -> float: + def _get_funding_fees_from_exchange(self, pair: str, since: Union[datetime, int]) -> float: """ Returns the sum of all funding fees that were exchanged for a pair within a timeframe + Dry-run handling happens as part of _calculate_funding_fees. :param pair: (e.g. ADA/USDT) :param since: The earliest time of consideration for calculating funding fees, in unix time or as a datetime """ - if not self.exchange_has("fetchFundingHistory"): raise OperationalException( - f"fetch_funding_history() has not been implemented on ccxt.{self.name}") + f"fetch_funding_history() is not available using {self.name}" + ) if type(since) is datetime: since = int(since.timestamp()) * 1000 # * 1000 for ms @@ -1672,6 +1670,25 @@ class Exchange: else: return 1.0 + def _get_funding_fee( + self, + size: float, + funding_rate: float, + mark_price: float, + time_in_ratio: Optional[float] = None + ) -> float: + """ + Calculates a single funding fee + :param size: contract size * number of contracts + :param mark_price: The price of the asset that the contract is based off of + :param funding_rate: the interest rate and the premium + - interest rate: + - premium: varies by price difference between the perpetual contract and mark price + :param time_in_ratio: Not used by most exchange classes + """ + nominal_value = mark_price * size + return nominal_value * funding_rate + @retrier def _set_leverage( self, @@ -1697,11 +1714,18 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e + def funding_fee_cutoff(self, open_date: datetime): + """ + :param open_date: The open date for a trade + :return: The cutoff open time for when a funding fee is charged + """ + return open_date.minute > 0 or open_date.second > 0 + @retrier def set_margin_mode(self, pair: str, collateral: Collateral, params: dict = {}): """ Set's the margin mode on the exchange to cross or isolated for a specific pair - :param symbol: base/quote currency pair (e.g. "ADA/USDT") + :param pair: base/quote currency pair (e.g. "ADA/USDT") """ if self._config['dry_run'] or not self.exchange_has("setMarginMode"): # Some exchanges only support one collateral type @@ -1717,6 +1741,150 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e + @retrier + def _get_mark_price_history(self, pair: str, since: int) -> Dict: + """ + Get's the mark price history for a pair + :param pair: The quote/base pair of the trade + :param since: The earliest time to start downloading candles, in ms. + """ + + try: + candles = self._api.fetch_ohlcv( + pair, + timeframe="1h", + since=since, + params={ + 'price': self._ft_has["mark_ohlcv_price"] + } + ) + history = {} + for candle in candles: + d = datetime.fromtimestamp(int(candle[0] / 1000), timezone.utc) + # Round down to the nearest hour, in case of a delayed timestamp + # The millisecond timestamps can be delayed ~20ms + time = timeframe_to_prev_date('1h', d).timestamp() * 1000 + opening_mark_price = candle[1] + history[time] = opening_mark_price + return history + except ccxt.NotSupported as e: + raise OperationalException( + f'Exchange {self._api.name} does not support fetching historical ' + f'mark price candle (OHLCV) data. Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError(f'Could not fetch historical mark price candle (OHLCV) data ' + f'for pair {pair} due to {e.__class__.__name__}. ' + f'Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(f'Could not fetch historical mark price candle (OHLCV) data ' + f'for pair {pair}. Message: {e}') from e + + def _calculate_funding_fees( + self, + pair: str, + amount: float, + open_date: datetime, + close_date: Optional[datetime] = None + ) -> float: + """ + calculates the sum of all funding fees that occurred for a pair during a futures trade + Only used during dry-run or if the exchange does not provide a funding_rates endpoint. + :param pair: The quote/base pair of the trade + :param amount: The quantity of the trade + :param open_date: The date and time that the trade started + :param close_date: The date and time that the trade ended + """ + + if self.funding_fee_cutoff(open_date): + open_date += timedelta(hours=1) + + open_date = timeframe_to_prev_date('1h', open_date) + + fees: float = 0 + if not close_date: + close_date = datetime.now(timezone.utc) + open_timestamp = int(open_date.timestamp()) * 1000 + # close_timestamp = int(close_date.timestamp()) * 1000 + funding_rate_history = self.get_funding_rate_history( + pair, + open_timestamp + ) + mark_price_history = self._get_mark_price_history( + pair, + open_timestamp + ) + for timestamp in funding_rate_history.keys(): + funding_rate = funding_rate_history[timestamp] + if timestamp in mark_price_history: + mark_price = mark_price_history[timestamp] + fees += self._get_funding_fee( + size=amount, + mark_price=mark_price, + funding_rate=funding_rate + ) + else: + logger.warning( + f"Mark price for {pair} at timestamp {timestamp} not found in " + f"funding_rate_history Funding fee calculation may be incorrect" + ) + + return fees + + def get_funding_fees(self, pair: str, amount: float, 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 amount: Trade amount + :param open_date: Open date of the trade + """ + if self.trading_mode == TradingMode.FUTURES: + if self._config['dry_run']: + funding_fees = self._calculate_funding_fees(pair, amount, open_date) + else: + funding_fees = self._get_funding_fees_from_exchange(pair, open_date) + return funding_fees + else: + return 0.0 + + @retrier + def get_funding_rate_history(self, pair: str, since: int) -> Dict: + """ + :param pair: quote/base currency pair + :param since: timestamp in ms of the beginning time + :param end: timestamp in ms of the end time + """ + if not self.exchange_has("fetchFundingRateHistory"): + raise ExchangeError( + f"fetch_funding_rate_history is not available using {self.name}" + ) + + # TODO-lev: Gateio has a max limit into the past of 333 days, okex has a limit of 3 months + try: + funding_history: Dict = {} + response = self._api.fetch_funding_rate_history( + pair, + limit=1000, + since=since + ) + for fund in response: + d = datetime.fromtimestamp(int(fund['timestamp'] / 1000), timezone.utc) + # Round down to the nearest hour, in case of a delayed timestamp + # The millisecond timestamps can be delayed ~20ms + time = int(timeframe_to_prev_date('1h', d).timestamp() * 1000) + + funding_history[time] = fund['fundingRate'] + return funding_history + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not set margin mode due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e + def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = None) -> bool: return exchange_name in ccxt_exchanges(ccxt_module) diff --git a/freqtrade/exchange/ftx.py b/freqtrade/exchange/ftx.py index d8df8bc97..1d06be821 100644 --- a/freqtrade/exchange/ftx.py +++ b/freqtrade/exchange/ftx.py @@ -20,8 +20,8 @@ class Ftx(Exchange): _ft_has: Dict = { "stoploss_on_exchange": True, "ohlcv_candle_limit": 1500, + "mark_ohlcv_price": "index" } - funding_fee_times: List[int] = list(range(0, 24)) _supported_trading_mode_collateral_pairs: List[Tuple[TradingMode, Collateral]] = [ # TradingMode.SPOT always supported and not required in this list diff --git a/freqtrade/exchange/gateio.py b/freqtrade/exchange/gateio.py index f80c977e7..a445fd70a 100644 --- a/freqtrade/exchange/gateio.py +++ b/freqtrade/exchange/gateio.py @@ -26,8 +26,6 @@ class Gateio(Exchange): _headers = {'X-Gate-Channel-Id': 'freqtrade'} - funding_fee_times: List[int] = [0, 8, 16] # hours of the day - _supported_trading_mode_collateral_pairs: List[Tuple[TradingMode, Collateral]] = [ # TradingMode.SPOT always supported and not required in this list # TODO-lev: Uncomment once supported diff --git a/freqtrade/exchange/hitbtc.py b/freqtrade/exchange/hitbtc.py index 8e0a009f0..a48c9a198 100644 --- a/freqtrade/exchange/hitbtc.py +++ b/freqtrade/exchange/hitbtc.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, List +from typing import Dict from freqtrade.exchange import Exchange @@ -21,5 +21,3 @@ class Hitbtc(Exchange): "ohlcv_candle_limit": 1000, "ohlcv_params": {"sort": "DESC"} } - - funding_fee_times: List[int] = [0, 8, 16] # hours of the day diff --git a/freqtrade/exchange/kraken.py b/freqtrade/exchange/kraken.py index 6d0294e1c..42d817222 100644 --- a/freqtrade/exchange/kraken.py +++ b/freqtrade/exchange/kraken.py @@ -23,7 +23,6 @@ class Kraken(Exchange): "trades_pagination": "id", "trades_pagination_arg": "since", } - funding_fee_times: List[int] = [0, 4, 8, 12, 16, 20] # hours of the day _supported_trading_mode_collateral_pairs: List[Tuple[TradingMode, Collateral]] = [ # TradingMode.SPOT always supported and not required in this list @@ -157,3 +156,29 @@ class Kraken(Exchange): if leverage > 1.0: params['leverage'] = leverage return params + + def _get_funding_fee( + self, + size: float, + funding_rate: float, + mark_price: float, + time_in_ratio: Optional[float] = None + ) -> float: + """ + # ! This method will always error when run by Freqtrade because time_in_ratio is never + # ! passed to _get_funding_fee. For kraken futures to work in dry run and backtesting + # ! functionality must be added that passes the parameter time_in_ratio to + # ! _get_funding_fee when using Kraken + Calculates a single funding fee + :param size: contract size * number of contracts + :param mark_price: The price of the asset that the contract is based off of + :param funding_rate: the interest rate and the premium + - interest rate: + - premium: varies by price difference between the perpetual contract and mark price + :param time_in_ratio: time elapsed within funding period without position alteration + """ + if not time_in_ratio: + raise OperationalException( + f"time_in_ratio is required for {self.name}._get_funding_fee") + nominal_value = mark_price * size + return nominal_value * funding_rate * time_in_ratio diff --git a/freqtrade/exchange/kucoin.py b/freqtrade/exchange/kucoin.py index 51de75ea4..5d818f6a2 100644 --- a/freqtrade/exchange/kucoin.py +++ b/freqtrade/exchange/kucoin.py @@ -1,6 +1,6 @@ """ Kucoin exchange subclass """ import logging -from typing import Dict, List +from typing import Dict from freqtrade.exchange import Exchange @@ -24,5 +24,3 @@ class Kucoin(Exchange): "order_time_in_force": ['gtc', 'fok', 'ioc'], "time_in_force_parameter": "timeInForce", } - - funding_fee_times: List[int] = [4, 12, 20] # hours of the day diff --git a/freqtrade/exchange/okex.py b/freqtrade/exchange/okex.py index 61f94ae65..98e493d9b 100644 --- a/freqtrade/exchange/okex.py +++ b/freqtrade/exchange/okex.py @@ -17,7 +17,6 @@ class Okex(Exchange): _ft_has: Dict = { "ohlcv_candle_limit": 100, } - funding_fee_times: List[int] = [0, 8, 16] # hours of the day _supported_trading_mode_collateral_pairs: List[Tuple[TradingMode, Collateral]] = [ # TradingMode.SPOT always supported and not required in this list diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 092d658c2..4fd6d9b1b 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -268,12 +268,16 @@ class FreqtradeBot(LoggingMixin): def update_funding_fees(self): if self.trading_mode == TradingMode.FUTURES: - for trade in Trade.get_open_trades(): - funding_fees = self.exchange.get_funding_fees_from_exchange( + trades = Trade.get_open_trades() + for trade in trades: + funding_fees = self.exchange.get_funding_fees( trade.pair, + trade.amount, trade.open_date ) trade.funding_fees = funding_fees + else: + return 0.0 def startup_update_open_orders(self): """ @@ -703,10 +707,7 @@ class FreqtradeBot(LoggingMixin): # Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker') open_date = datetime.now(timezone.utc) - if self.trading_mode == TradingMode.FUTURES: - funding_fees = self.exchange.get_funding_fees_from_exchange(pair, open_date) - else: - funding_fees = 0.0 + funding_fees = self.exchange.get_funding_fees(pair, amount, open_date) trade = Trade( pair=pair, @@ -1260,6 +1261,11 @@ class FreqtradeBot(LoggingMixin): :param sell_reason: Reason the sell was triggered :return: True if it succeeds (supported) False (not supported) """ + trade.funding_fees = self.exchange.get_funding_fees( + trade.pair, + trade.amount, + trade.open_date + ) exit_type = 'sell' # TODO-lev: Update to exit if sell_reason.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS): exit_type = 'stoploss' diff --git a/tests/conftest.py b/tests/conftest.py index 0fa7daf59..31ddb05e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2368,3 +2368,131 @@ def limit_order_open(limit_buy_order_usdt_open, limit_sell_order_usdt_open): 'buy': limit_buy_order_usdt_open, 'sell': limit_sell_order_usdt_open } + + +@pytest.fixture(scope='function') +def mark_ohlcv(): + return [ + [1630454400000, 2.77, 2.77, 2.73, 2.73, 0], + [1630458000000, 2.73, 2.76, 2.72, 2.74, 0], + [1630461600000, 2.74, 2.76, 2.74, 2.76, 0], + [1630465200000, 2.76, 2.76, 2.74, 2.76, 0], + [1630468800000, 2.76, 2.77, 2.75, 2.77, 0], + [1630472400000, 2.77, 2.79, 2.75, 2.78, 0], + [1630476000000, 2.78, 2.80, 2.77, 2.77, 0], + [1630479600000, 2.78, 2.79, 2.77, 2.77, 0], + [1630483200000, 2.77, 2.79, 2.77, 2.78, 0], + [1630486800000, 2.77, 2.84, 2.77, 2.84, 0], + [1630490400000, 2.84, 2.85, 2.81, 2.81, 0], + [1630494000000, 2.81, 2.83, 2.81, 2.81, 0], + [1630497600000, 2.81, 2.84, 2.81, 2.82, 0], + [1630501200000, 2.82, 2.83, 2.81, 2.81, 0], + ] + + +@pytest.fixture(scope='function') +def funding_rate_history_hourly(): + return [ + { + "symbol": "ADA/USDT", + "fundingRate": -0.000008, + "timestamp": 1630454400000, + "datetime": "2021-09-01T00:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": -0.000004, + "timestamp": 1630458000000, + "datetime": "2021-09-01T01:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": 0.000012, + "timestamp": 1630461600000, + "datetime": "2021-09-01T02:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": -0.000003, + "timestamp": 1630465200000, + "datetime": "2021-09-01T03:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": -0.000007, + "timestamp": 1630468800000, + "datetime": "2021-09-01T04:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": 0.000003, + "timestamp": 1630472400000, + "datetime": "2021-09-01T05:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": 0.000019, + "timestamp": 1630476000000, + "datetime": "2021-09-01T06:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": 0.000003, + "timestamp": 1630479600000, + "datetime": "2021-09-01T07:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": -0.000003, + "timestamp": 1630483200000, + "datetime": "2021-09-01T08:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": 0, + "timestamp": 1630486800000, + "datetime": "2021-09-01T09:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": 0.000013, + "timestamp": 1630490400000, + "datetime": "2021-09-01T10:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": 0.000077, + "timestamp": 1630494000000, + "datetime": "2021-09-01T11:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": 0.000072, + "timestamp": 1630497600000, + "datetime": "2021-09-01T12:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": 0.000097, + "timestamp": 1630501200000, + "datetime": "2021-09-01T13:00:00.000Z" + }, + ] + + +@pytest.fixture(scope='function') +def funding_rate_history_octohourly(): + return [ + { + "symbol": "ADA/USDT", + "fundingRate": -0.000008, + "timestamp": 1630454400000, + "datetime": "2021-09-01T00:00:00.000Z" + }, + { + "symbol": "ADA/USDT", + "fundingRate": -0.000003, + "timestamp": 1630483200000, + "datetime": "2021-09-01T08:00:00.000Z" + } + ] diff --git a/tests/exchange/test_ccxt_compat.py b/tests/exchange/test_ccxt_compat.py index 2f629528c..b14df070c 100644 --- a/tests/exchange/test_ccxt_compat.py +++ b/tests/exchange/test_ccxt_compat.py @@ -12,7 +12,7 @@ import pytest from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date from freqtrade.resolvers.exchange_resolver import ExchangeResolver -from tests.conftest import get_default_conf +from tests.conftest import get_default_conf_usdt # Exchanges that should be tested @@ -33,9 +33,11 @@ EXCHANGES = { 'timeframe': '5m', }, 'ftx': { - 'pair': 'BTC/USDT', + 'pair': 'BTC/USD', 'hasQuoteVolume': True, 'timeframe': '5m', + 'futures_pair': 'BTC-PERP', + 'futures': True, }, 'kucoin': { 'pair': 'BTC/USDT', @@ -46,18 +48,24 @@ EXCHANGES = { 'pair': 'BTC/USDT', 'hasQuoteVolume': True, 'timeframe': '5m', + 'futures': True, + 'futures_fundingrate_tf': '8h', + 'futures_pair': 'BTC/USDT:USDT', }, 'okex': { 'pair': 'BTC/USDT', 'hasQuoteVolume': True, 'timeframe': '5m', + 'futures_fundingrate_tf': '8h', + 'futures_pair': 'BTC/USDT:USDT', + 'futures': True, }, } @pytest.fixture(scope="class") def exchange_conf(): - config = get_default_conf((Path(__file__).parent / "testdata").resolve()) + config = get_default_conf_usdt((Path(__file__).parent / "testdata").resolve()) config['exchange']['pair_whitelist'] = [] config['exchange']['key'] = '' config['exchange']['secret'] = '' @@ -73,6 +81,19 @@ def exchange(request, exchange_conf): yield exchange, request.param +@pytest.fixture(params=EXCHANGES, scope="class") +def exchange_futures(request, exchange_conf): + if not EXCHANGES[request.param].get('futures') is True: + yield None, request.param + else: + exchange_conf['exchange']['name'] = request.param + exchange_conf['trading_mode'] = 'futures' + exchange_conf['collateral'] = 'cross' + exchange = ExchangeResolver.load_exchange(request.param, exchange_conf, validate=True) + + yield exchange, request.param + + @pytest.mark.longrun class TestCCXTExchange(): @@ -149,6 +170,25 @@ class TestCCXTExchange(): now = datetime.now(timezone.utc) - timedelta(minutes=(timeframe_to_minutes(timeframe) * 2)) assert exchange.klines(pair_tf).iloc[-1]['date'] >= timeframe_to_prev_date(timeframe, now) + @pytest.mark.skip("No futures support yet") + def test_ccxt_fetch_funding_rate_history(self, exchange_futures): + # TODO-lev: enable this test once Futures mode is enabled. + exchange, exchangename = exchange_futures + if not exchange: + # exchange_futures only returns values for supported exchanges + return + + pair = EXCHANGES[exchangename].get('futures_pair', EXCHANGES[exchangename]['pair']) + since = int((datetime.now(timezone.utc) - timedelta(days=5)).timestamp() * 1000) + + rate = exchange.get_funding_rate_history(pair, since) + assert isinstance(rate, dict) + expected_tf = EXCHANGES[exchangename].get('futures_fundingrate_tf', '1h') + this_hour = timeframe_to_prev_date(expected_tf) + prev_tick = timeframe_to_prev_date(expected_tf, this_hour - timedelta(minutes=1)) + assert rate[int(this_hour.timestamp() * 1000)] != 0.0 + assert rate[int(prev_tick.timestamp() * 1000)] != 0.0 + # TODO: tests fetch_trades (?) def test_ccxt_get_fee(self, exchange): diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 1846b3721..0e2824b6c 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -2896,6 +2896,8 @@ def test_timeframe_to_prev_date(): # Does not round time = datetime(2019, 8, 12, 13, 20, 0, tzinfo=timezone.utc) assert timeframe_to_prev_date('5m', time) == time + time = datetime(2019, 8, 12, 13, 0, 0, tzinfo=timezone.utc) + assert timeframe_to_prev_date('1h', time) == time def test_timeframe_to_next_date(): @@ -3058,7 +3060,7 @@ def test_calculate_backoff(retrycount, max_retries, expected): @pytest.mark.parametrize("exchange_name", ['binance', 'ftx']) -def test_get_funding_fees_from_exchange(default_conf, mocker, exchange_name): +def test__get_funding_fees_from_exchange(default_conf, mocker, exchange_name): api_mock = MagicMock() api_mock.fetch_funding_history = MagicMock(return_value=[ { @@ -3101,11 +3103,11 @@ def test_get_funding_fees_from_exchange(default_conf, mocker, exchange_name): date_time = datetime.strptime("2021-09-01T00:00:01.000Z", '%Y-%m-%dT%H:%M:%S.%fZ') unix_time = int(date_time.timestamp()) expected_fees = -0.001 # 0.14542341 + -0.14642341 - fees_from_datetime = exchange.get_funding_fees_from_exchange( + fees_from_datetime = exchange._get_funding_fees_from_exchange( pair='XRP/USDT', since=date_time ) - fees_from_unix_time = exchange.get_funding_fees_from_exchange( + fees_from_unix_time = exchange._get_funding_fees_from_exchange( pair='XRP/USDT', since=unix_time ) @@ -3118,7 +3120,7 @@ def test_get_funding_fees_from_exchange(default_conf, mocker, exchange_name): default_conf, api_mock, exchange_name, - "get_funding_fees_from_exchange", + "_get_funding_fees_from_exchange", "fetch_funding_history", pair="XRP/USDT", since=unix_time @@ -3288,3 +3290,226 @@ def test_get_max_leverage(default_conf, mocker, pair, nominal_value, max_lev): # Binance has a different method of getting the max leverage exchange = get_patched_exchange(mocker, default_conf, id="kraken") assert exchange.get_max_leverage(pair, nominal_value) == max_lev + + +@pytest.mark.parametrize( + 'size,funding_rate,mark_price,time_in_ratio,funding_fee,kraken_fee', [ + (10, 0.0001, 2.0, 1.0, 0.002, 0.002), + (10, 0.0002, 2.0, 0.01, 0.004, 0.00004), + (10, 0.0002, 2.5, None, 0.005, None), + ]) +def test__get_funding_fee( + default_conf, + mocker, + size, + funding_rate, + mark_price, + funding_fee, + kraken_fee, + time_in_ratio +): + exchange = get_patched_exchange(mocker, default_conf) + kraken = get_patched_exchange(mocker, default_conf, id="kraken") + + assert exchange._get_funding_fee(size, funding_rate, mark_price, time_in_ratio) == funding_fee + + if (kraken_fee is None): + with pytest.raises(OperationalException): + kraken._get_funding_fee(size, funding_rate, mark_price, time_in_ratio) + else: + assert kraken._get_funding_fee(size, funding_rate, mark_price, time_in_ratio) == kraken_fee + + +def test__get_mark_price_history(mocker, default_conf, mark_ohlcv): + api_mock = MagicMock() + api_mock.fetch_ohlcv = MagicMock(return_value=mark_ohlcv) + type(api_mock).has = PropertyMock(return_value={'fetchOHLCV': True}) + + # mocker.patch('freqtrade.exchange.Exchange.get_funding_fees', lambda pair, since: y) + exchange = get_patched_exchange(mocker, default_conf, api_mock) + mark_prices = exchange._get_mark_price_history("ADA/USDT", 1630454400000) + assert mark_prices == { + 1630454400000: 2.77, + 1630458000000: 2.73, + 1630461600000: 2.74, + 1630465200000: 2.76, + 1630468800000: 2.76, + 1630472400000: 2.77, + 1630476000000: 2.78, + 1630479600000: 2.78, + 1630483200000: 2.77, + 1630486800000: 2.77, + 1630490400000: 2.84, + 1630494000000: 2.81, + 1630497600000: 2.81, + 1630501200000: 2.82, + } + + ccxt_exceptionhandlers( + mocker, + default_conf, + api_mock, + "binance", + "_get_mark_price_history", + "fetch_ohlcv", + pair="ADA/USDT", + since=1635580800001 + ) + + +def test_get_funding_rate_history(mocker, default_conf, funding_rate_history_hourly): + api_mock = MagicMock() + api_mock.fetch_funding_rate_history = MagicMock(return_value=funding_rate_history_hourly) + type(api_mock).has = PropertyMock(return_value={'fetchFundingRateHistory': True}) + + # mocker.patch('freqtrade.exchange.Exchange.get_funding_fees', lambda pair, since: y) + exchange = get_patched_exchange(mocker, default_conf, api_mock) + funding_rates = exchange.get_funding_rate_history('ADA/USDT', 1635580800001) + + assert funding_rates == { + 1630454400000: -0.000008, + 1630458000000: -0.000004, + 1630461600000: 0.000012, + 1630465200000: -0.000003, + 1630468800000: -0.000007, + 1630472400000: 0.000003, + 1630476000000: 0.000019, + 1630479600000: 0.000003, + 1630483200000: -0.000003, + 1630486800000: 0, + 1630490400000: 0.000013, + 1630494000000: 0.000077, + 1630497600000: 0.000072, + 1630501200000: 0.000097, + } + + ccxt_exceptionhandlers( + mocker, + default_conf, + api_mock, + "binance", + "get_funding_rate_history", + "fetch_funding_rate_history", + pair="ADA/USDT", + since=1630454400000 + ) + + +@pytest.mark.parametrize('exchange,rate_start,rate_end,d1,d2,amount,expected_fees', [ + ('binance', 0, 2, "2021-09-01 00:00:00", "2021-09-01 08:00:00", 30.0, -0.0009140999999999999), + ('binance', 0, 2, "2021-09-01 00:00:15", "2021-09-01 08:00:00", 30.0, -0.0009140999999999999), + ('binance', 1, 2, "2021-09-01 01:00:14", "2021-09-01 08:00:00", 30.0, -0.0002493), + ('binance', 1, 2, "2021-09-01 00:00:16", "2021-09-01 08:00:00", 30.0, -0.0002493), + ('binance', 0, 1, "2021-09-01 00:00:00", "2021-09-01 07:59:59", 30.0, -0.0006647999999999999), + ('binance', 0, 2, "2021-09-01 00:00:00", "2021-09-01 12:00:00", 30.0, -0.0009140999999999999), + ('binance', 0, 2, "2021-09-01 00:00:01", "2021-09-01 08:00:00", 30.0, -0.0009140999999999999), + # TODO: Uncoment once _calculate_funding_fees can pas time_in_ratio to exchange._get_funding_fee + # ('kraken', "2021-09-01 00:00:00", "2021-09-01 08:00:00", 30.0, -0.0014937), + # ('kraken', "2021-09-01 00:00:15", "2021-09-01 08:00:00", 30.0, -0.0008289), + # ('kraken', "2021-09-01 01:00:14", "2021-09-01 08:00:00", 30.0, -0.0008289), + # ('kraken', "2021-09-01 00:00:00", "2021-09-01 07:59:59", 30.0, -0.0012443999999999999), + # ('kraken', "2021-09-01 00:00:00", "2021-09-01 12:00:00", 30.0, 0.0045759), + # ('kraken', "2021-09-01 00:00:01", "2021-09-01 08:00:00", 30.0, -0.0008289), + ('ftx', 0, 9, "2021-09-01 00:00:00", "2021-09-01 08:00:00", 30.0, 0.0010008000000000003), + ('ftx', 0, 13, "2021-09-01 00:00:00", "2021-09-01 12:00:00", 30.0, 0.0146691), + ('ftx', 1, 9, "2021-09-01 00:00:01", "2021-09-01 08:00:00", 30.0, 0.0016656000000000002), + ('gateio', 0, 2, "2021-09-01 00:00:00", "2021-09-01 08:00:00", 30.0, -0.0009140999999999999), + ('gateio', 0, 2, "2021-09-01 00:00:00", "2021-09-01 12:00:00", 30.0, -0.0009140999999999999), + ('gateio', 1, 2, "2021-09-01 00:00:01", "2021-09-01 08:00:00", 30.0, -0.0002493), + ('binance', 0, 2, "2021-09-01 00:00:00", "2021-09-01 08:00:00", 50.0, -0.0015235000000000001), + # TODO: Uncoment once _calculate_funding_fees can pas time_in_ratio to exchange._get_funding_fee + # ('kraken', "2021-09-01 00:00:00", "2021-09-01 08:00:00", 50.0, -0.0024895), + ('ftx', 0, 9, "2021-09-01 00:00:00", "2021-09-01 08:00:00", 50.0, 0.0016680000000000002), +]) +def test__calculate_funding_fees( + mocker, + default_conf, + funding_rate_history_hourly, + funding_rate_history_octohourly, + rate_start, + rate_end, + mark_ohlcv, + exchange, + d1, + d2, + amount, + expected_fees +): + ''' + nominal_value = mark_price * size + funding_fee = nominal_value * funding_rate + size: 30 + time: 0, mark: 2.77, nominal_value: 83.1, fundRate: -0.000008, fundFee: -0.0006648 + time: 1, mark: 2.73, nominal_value: 81.9, fundRate: -0.000004, fundFee: -0.0003276 + time: 2, mark: 2.74, nominal_value: 82.2, fundRate: 0.000012, fundFee: 0.0009864 + time: 3, mark: 2.76, nominal_value: 82.8, fundRate: -0.000003, fundFee: -0.0002484 + time: 4, mark: 2.76, nominal_value: 82.8, fundRate: -0.000007, fundFee: -0.0005796 + time: 5, mark: 2.77, nominal_value: 83.1, fundRate: 0.000003, fundFee: 0.0002493 + time: 6, mark: 2.78, nominal_value: 83.4, fundRate: 0.000019, fundFee: 0.0015846 + time: 7, mark: 2.78, nominal_value: 83.4, fundRate: 0.000003, fundFee: 0.0002502 + time: 8, mark: 2.77, nominal_value: 83.1, fundRate: -0.000003, fundFee: -0.0002493 + time: 9, mark: 2.77, nominal_value: 83.1, fundRate: 0, fundFee: 0.0 + time: 10, mark: 2.84, nominal_value: 85.2, fundRate: 0.000013, fundFee: 0.0011076 + time: 11, mark: 2.81, nominal_value: 84.3, fundRate: 0.000077, fundFee: 0.0064911 + time: 12, mark: 2.81, nominal_value: 84.3, fundRate: 0.000072, fundFee: 0.0060696 + time: 13, mark: 2.82, nominal_value: 84.6, fundRate: 0.000097, fundFee: 0.0082062 + + size: 50 + time: 0, mark: 2.77, nominal_value: 138.5, fundRate: -0.000008, fundFee: -0.001108 + time: 1, mark: 2.73, nominal_value: 136.5, fundRate: -0.000004, fundFee: -0.000546 + time: 2, mark: 2.74, nominal_value: 137.0, fundRate: 0.000012, fundFee: 0.001644 + time: 3, mark: 2.76, nominal_value: 138.0, fundRate: -0.000003, fundFee: -0.000414 + time: 4, mark: 2.76, nominal_value: 138.0, fundRate: -0.000007, fundFee: -0.000966 + time: 5, mark: 2.77, nominal_value: 138.5, fundRate: 0.000003, fundFee: 0.0004155 + time: 6, mark: 2.78, nominal_value: 139.0, fundRate: 0.000019, fundFee: 0.002641 + time: 7, mark: 2.78, nominal_value: 139.0, fundRate: 0.000003, fundFee: 0.000417 + time: 8, mark: 2.77, nominal_value: 138.5, fundRate: -0.000003, fundFee: -0.0004155 + time: 9, mark: 2.77, nominal_value: 138.5, fundRate: 0, fundFee: 0.0 + time: 10, mark: 2.84, nominal_value: 142.0, fundRate: 0.000013, fundFee: 0.001846 + time: 11, mark: 2.81, nominal_value: 140.5, fundRate: 0.000077, fundFee: 0.0108185 + time: 12, mark: 2.81, nominal_value: 140.5, fundRate: 0.000072, fundFee: 0.010116 + time: 13, mark: 2.82, nominal_value: 141.0, fundRate: 0.000097, fundFee: 0.013677 + ''' + d1 = datetime.strptime(f"{d1} +0000", '%Y-%m-%d %H:%M:%S %z') + d2 = datetime.strptime(f"{d2} +0000", '%Y-%m-%d %H:%M:%S %z') + funding_rate_history = { + 'binance': funding_rate_history_octohourly, + 'ftx': funding_rate_history_hourly, + 'gateio': funding_rate_history_octohourly, + }[exchange][rate_start:rate_end] + api_mock = MagicMock() + api_mock.fetch_funding_rate_history = MagicMock(return_value=funding_rate_history) + api_mock.fetch_ohlcv = MagicMock(return_value=mark_ohlcv) + type(api_mock).has = PropertyMock(return_value={'fetchOHLCV': True}) + type(api_mock).has = PropertyMock(return_value={'fetchFundingRateHistory': True}) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange) + funding_fees = exchange._calculate_funding_fees('ADA/USDT', amount, d1, d2) + assert funding_fees == expected_fees + + +@ pytest.mark.parametrize('exchange,expected_fees', [ + ('binance', -0.0009140999999999999), + ('gateio', -0.0009140999999999999), +]) +def test__calculate_funding_fees_datetime_called( + mocker, + default_conf, + funding_rate_history_octohourly, + mark_ohlcv, + exchange, + time_machine, + expected_fees +): + api_mock = MagicMock() + api_mock.fetch_ohlcv = MagicMock(return_value=mark_ohlcv) + api_mock.fetch_funding_rate_history = MagicMock(return_value=funding_rate_history_octohourly) + type(api_mock).has = PropertyMock(return_value={'fetchOHLCV': True}) + type(api_mock).has = PropertyMock(return_value={'fetchFundingRateHistory': True}) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange) + d1 = datetime.strptime("2021-09-01 00:00:00 +0000", '%Y-%m-%d %H:%M:%S %z') + + time_machine.move_to("2021-09-01 08:00:00 +00:00") + funding_fees = exchange._calculate_funding_fees('ADA/USDT', 30.0, d1) + assert funding_fees == expected_fees diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 019fa42c7..fbf700d94 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -4700,8 +4700,8 @@ def test_leverage_prep(): ('futures', 33, "2021-08-31 23:59:59", "2021-09-01 08:00:07"), ('futures', 33, "2021-08-31 23:59:58", "2021-09-01 08:00:07"), ]) -def test_update_funding_fees(mocker, default_conf, trading_mode, calls, time_machine, - t1, t2): +def test_update_funding_fees_schedule(mocker, default_conf, trading_mode, calls, time_machine, + t1, t2): time_machine.move_to(f"{t1} +00:00") patch_RPCManager(mocker) @@ -4716,3 +4716,159 @@ def test_update_funding_fees(mocker, default_conf, trading_mode, calls, time_mac freqtrade._schedule.run_pending() assert freqtrade.update_funding_fees.call_count == calls + + +@pytest.mark.parametrize('schedule_off', [False, True]) +@pytest.mark.parametrize('is_short', [True, False]) +def test_update_funding_fees( + mocker, + default_conf, + time_machine, + fee, + ticker_usdt_sell_up, + is_short, + limit_order_open, + schedule_off +): + ''' + nominal_value = mark_price * size + funding_fee = nominal_value * funding_rate + size = 123 + "LTC/BTC" + time: 0, mark: 3.3, fundRate: 0.00032583, nominal_value: 405.9, fundFee: 0.132254397 + time: 8, mark: 3.2, fundRate: 0.00024472, nominal_value: 393.6, fundFee: 0.096321792 + "ETH/BTC" + time: 0, mark: 2.4, fundRate: 0.0001, nominal_value: 295.2, fundFee: 0.02952 + time: 8, mark: 2.5, fundRate: 0.0001, nominal_value: 307.5, fundFee: 0.03075 + "ETC/BTC" + time: 0, mark: 4.3, fundRate: 0.00031077, nominal_value: 528.9, fundFee: 0.164366253 + time: 8, mark: 4.1, fundRate: 0.00022655, nominal_value: 504.3, fundFee: 0.114249165 + "XRP/BTC" + time: 0, mark: 1.2, fundRate: 0.00049426, nominal_value: 147.6, fundFee: 0.072952776 + time: 8, mark: 1.2, fundRate: 0.00032715, nominal_value: 147.6, fundFee: 0.04828734 + ''' + # SETUP + time_machine.move_to("2021-09-01 00:00:00 +00:00") + + open_order = limit_order_open[enter_side(is_short)] + open_exit_order = limit_order_open[exit_side(is_short)] + bid = 0.11 + enter_rate_mock = MagicMock(return_value=bid) + enter_mm = MagicMock(return_value=open_order) + patch_RPCManager(mocker) + patch_exchange(mocker) + default_conf['trading_mode'] = 'futures' + default_conf['collateral'] = 'isolated' + default_conf['dry_run'] = True + timestamp_midnight = 1630454400000 + timestamp_eight = 1630483200000 + funding_rates_midnight = { + "LTC/BTC": { + timestamp_midnight: 0.00032583, + }, + "ETH/BTC": { + timestamp_midnight: 0.0001, + }, + "XRP/BTC": { + timestamp_midnight: 0.00049426, + } + } + + funding_rates_eight = { + "LTC/BTC": { + timestamp_midnight: 0.00032583, + timestamp_eight: 0.00024472, + }, + "ETH/BTC": { + timestamp_midnight: 0.0001, + timestamp_eight: 0.0001, + }, + "XRP/BTC": { + timestamp_midnight: 0.00049426, + timestamp_eight: 0.00032715, + } + } + + mark_prices = { + "LTC/BTC": { + timestamp_midnight: 3.3, + timestamp_eight: 3.2, + }, + "ETH/BTC": { + timestamp_midnight: 2.4, + timestamp_eight: 2.5, + }, + "XRP/BTC": { + timestamp_midnight: 1.2, + timestamp_eight: 1.2, + } + } + + mocker.patch( + 'freqtrade.exchange.Exchange._get_mark_price_history', + side_effect=lambda pair, since: mark_prices[pair] + ) + + mocker.patch( + 'freqtrade.exchange.Exchange.get_funding_rate_history', + side_effect=lambda pair, since: funding_rates_midnight[pair] + ) + + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_rate=enter_rate_mock, + fetch_ticker=MagicMock(return_value={ + 'bid': 1.9, + 'ask': 2.2, + 'last': 1.9 + }), + create_order=enter_mm, + get_min_pair_stake_amount=MagicMock(return_value=1), + get_fee=fee, + ) + + freqtrade = get_patched_freqtradebot(mocker, default_conf) + + # initial funding fees, + freqtrade.execute_entry('ETH/BTC', 123) + freqtrade.execute_entry('LTC/BTC', 2.0) + freqtrade.execute_entry('XRP/BTC', 123) + + trades = Trade.get_open_trades() + assert len(trades) == 3 + for trade in trades: + assert trade.funding_fees == ( + trade.amount * + mark_prices[trade.pair][timestamp_midnight] * + funding_rates_midnight[trade.pair][timestamp_midnight] + ) + mocker.patch('freqtrade.exchange.Exchange.create_order', return_value=open_exit_order) + # create_mock_trades(fee, False) + time_machine.move_to("2021-09-01 08:00:00 +00:00") + mocker.patch( + 'freqtrade.exchange.Exchange.get_funding_rate_history', + side_effect=lambda pair, since: funding_rates_eight[pair] + ) + if schedule_off: + for trade in trades: + assert trade.funding_fees == ( + trade.amount * + mark_prices[trade.pair][timestamp_midnight] * + funding_rates_eight[trade.pair][timestamp_midnight] + ) + freqtrade.execute_trade_exit( + trade=trade, + # The values of the next 2 params are irrelevant for this test + limit=ticker_usdt_sell_up()['bid'], + sell_reason=SellCheckTuple(sell_type=SellType.ROI) + ) + else: + freqtrade._schedule.run_pending() + + # Funding fees for 00:00 and 08:00 + for trade in trades: + assert trade.funding_fees == sum([ + trade.amount * + mark_prices[trade.pair][time] * + funding_rates_eight[trade.pair][time] for time in mark_prices[trade.pair].keys() + ])