diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index e27a56aff..67692cd27 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -75,6 +75,7 @@ class Exchange: "mark_ohlcv_price": "mark", "mark_ohlcv_timeframe": "8h", "ccxt_futures_name": "swap", + "needs_trading_fees": False, # use fetch_trading_fees to cache fees } _ft_has: Dict = {} _ft_has_futures: Dict = {} @@ -92,6 +93,7 @@ class Exchange: self._api: ccxt.Exchange = None self._api_async: ccxt_async.Exchange = None self._markets: Dict = {} + self._trading_fees: Dict[str, Any] = {} self._leverage_tiers: Dict[str, List[Dict]] = {} self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) @@ -451,6 +453,9 @@ class Exchange: self._markets = self._api.load_markets() self._load_async_markets() self._last_markets_refresh = arrow.utcnow().int_timestamp + if self._ft_has['needs_trading_fees']: + self._trading_fees = self.fetch_trading_fees() + except ccxt.BaseError: logger.exception('Unable to initialize markets.') @@ -1299,6 +1304,27 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e + @retrier + def fetch_trading_fees(self) -> Dict[str, Any]: + """ + Fetch user account trading fees + Can be cached, should not update often. + """ + if (self._config['dry_run'] or self.trading_mode != TradingMode.FUTURES + or not self.exchange_has('fetchTradingFees')): + return {} + try: + trading_fees: Dict[str, Any] = self._api.fetch_trading_fees() + self._log_exchange_response('fetch_trading_fees', trading_fees) + return trading_fees + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not fetch trading fees due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e + @retrier def fetch_bids_asks(self, symbols: List[str] = None, cached: bool = False) -> Dict: """ diff --git a/freqtrade/exchange/gateio.py b/freqtrade/exchange/gateio.py index 50ff0c872..609cf4901 100644 --- a/freqtrade/exchange/gateio.py +++ b/freqtrade/exchange/gateio.py @@ -1,6 +1,7 @@ """ Gate.io exchange subclass """ import logging -from typing import Dict, List, Tuple +from datetime import datetime +from typing import Dict, List, Optional, Tuple from freqtrade.enums import MarginMode, TradingMode from freqtrade.exceptions import OperationalException @@ -27,6 +28,10 @@ class Gateio(Exchange): "stoploss_on_exchange": True, } + _ft_has_futures: Dict = { + "needs_trading_fees": True + } + _supported_trading_mode_margin_pairs: List[Tuple[TradingMode, MarginMode]] = [ # TradingMode.SPOT always supported and not required in this list # (TradingMode.MARGIN, MarginMode.CROSS), @@ -42,6 +47,30 @@ class Gateio(Exchange): raise OperationalException( f'Exchange {self.name} does not support market orders.') + def get_trades_for_order(self, order_id: str, pair: str, since: datetime, + params: Optional[Dict] = None) -> List: + trades = super().get_trades_for_order(order_id, pair, since, params) + + if self.trading_mode == TradingMode.FUTURES: + # Futures usually don't contain fees in the response. + # As such, futures orders on gateio will not contain a fee, which causes + # a repeated "update fee" cycle and wrong calculations. + # Therefore we patch the response with fees if it's not available. + # An alternative also contianing fees would be + # privateFuturesGetSettleAccountBook({"settle": "usdt"}) + pair_fees = self._trading_fees.get(pair, {}) + if pair_fees: + for idx, trade in enumerate(trades): + if trade.get('fee', {}).get('cost') is None: + takerOrMaker = trade.get('takerOrMaker', 'taker') + if pair_fees.get(takerOrMaker) is not None: + trades[idx]['fee'] = { + 'currency': self.get_pair_quote_currency(pair), + 'cost': trade['cost'] * pair_fees[takerOrMaker], + 'rate': pair_fees[takerOrMaker], + } + return trades + def fetch_stoploss_order(self, order_id: str, pair: str, params={}) -> Dict: return self.fetch_order( order_id=order_id, diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 089a5804a..ebc129777 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1564,6 +1564,7 @@ class FreqtradeBot(LoggingMixin): if not order_obj: raise DependencyException( f"Order_obj not found for {order_id}. This should not have happened.") + self.handle_order_fee(trade, order_obj, order) trade.update_trade(order_obj) diff --git a/tests/exchange/test_ccxt_compat.py b/tests/exchange/test_ccxt_compat.py index 5eb7e68d4..89b3bcc1f 100644 --- a/tests/exchange/test_ccxt_compat.py +++ b/tests/exchange/test_ccxt_compat.py @@ -134,7 +134,7 @@ def exchange_futures(request, exchange_conf, class_mocker): class_mocker.patch( 'freqtrade.exchange.binance.Binance.fill_leverage_tiers') - + class_mocker.patch('freqtrade.exchange.exchange.Exchange.fetch_trading_fees') exchange = ExchangeResolver.load_exchange(request.param, exchange_conf, validate=True) yield exchange, request.param diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 815ebcec2..5d16c3501 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1624,6 +1624,62 @@ def test_fetch_positions(default_conf, mocker, exchange_name): "fetch_positions", "fetch_positions") +def test_fetch_trading_fees(default_conf, mocker): + api_mock = MagicMock() + tick = { + '1INCH/USDT:USDT': { + 'info': {'user_id': '', + 'taker_fee': '0.0018', + 'maker_fee': '0.0018', + 'gt_discount': False, + 'gt_taker_fee': '0', + 'gt_maker_fee': '0', + 'loan_fee': '0.18', + 'point_type': '1', + 'futures_taker_fee': '0.0005', + 'futures_maker_fee': '0'}, + 'symbol': '1INCH/USDT:USDT', + 'maker': 0.0, + 'taker': 0.0005}, + 'ETH/USDT:USDT': { + 'info': {'user_id': '', + 'taker_fee': '0.0018', + 'maker_fee': '0.0018', + 'gt_discount': False, + 'gt_taker_fee': '0', + 'gt_maker_fee': '0', + 'loan_fee': '0.18', + 'point_type': '1', + 'futures_taker_fee': '0.0005', + 'futures_maker_fee': '0'}, + 'symbol': 'ETH/USDT:USDT', + 'maker': 0.0, + 'taker': 0.0005} + } + exchange_name = 'gateio' + default_conf['dry_run'] = False + default_conf['trading_mode'] = TradingMode.FUTURES + default_conf['margin_mode'] = MarginMode.ISOLATED + api_mock.fetch_trading_fees = MagicMock(return_value=tick) + mocker.patch('freqtrade.exchange.Exchange.exchange_has', return_value=True) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + + assert '1INCH/USDT:USDT' in exchange._trading_fees + assert 'ETH/USDT:USDT' in exchange._trading_fees + assert api_mock.fetch_trading_fees.call_count == 1 + + api_mock.fetch_trading_fees.reset_mock() + + ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, + "fetch_trading_fees", "fetch_trading_fees") + + api_mock.fetch_trading_fees = MagicMock(return_value={}) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + exchange.fetch_trading_fees() + mocker.patch('freqtrade.exchange.Exchange.exchange_has', return_value=True) + assert exchange.fetch_trading_fees() == {} + + def test_fetch_bids_asks(default_conf, mocker): api_mock = MagicMock() tick = {'ETH/BTC': { diff --git a/tests/exchange/test_gateio.py b/tests/exchange/test_gateio.py index 5a46e30a6..ad30a7d86 100644 --- a/tests/exchange/test_gateio.py +++ b/tests/exchange/test_gateio.py @@ -1,7 +1,9 @@ +from datetime import datetime, timezone from unittest.mock import MagicMock import pytest +from freqtrade.enums import MarginMode, TradingMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import Gateio from freqtrade.resolvers.exchange_resolver import ExchangeResolver @@ -70,3 +72,47 @@ def test_stoploss_adjust_gateio(mocker, default_conf, sl1, sl2, sl3, side): } assert exchange.stoploss_adjust(sl1, order, side) assert not exchange.stoploss_adjust(sl2, order, side) + + +@pytest.mark.parametrize('takerormaker,rate,cost', [ + ('taker', 0.0005, 0.0001554325), + ('maker', 0.0, 0.0), +]) +def test_fetch_my_trades_gateio(mocker, default_conf, takerormaker, rate, cost): + mocker.patch('freqtrade.exchange.Exchange.exchange_has', return_value=True) + tick = {'ETH/USDT:USDT': { + 'info': {'user_id': '', + 'taker_fee': '0.0018', + 'maker_fee': '0.0018', + 'gt_discount': False, + 'gt_taker_fee': '0', + 'gt_maker_fee': '0', + 'loan_fee': '0.18', + 'point_type': '1', + 'futures_taker_fee': '0.0005', + 'futures_maker_fee': '0'}, + 'symbol': 'ETH/USDT:USDT', + 'maker': 0.0, + 'taker': 0.0005} + } + default_conf['dry_run'] = False + default_conf['trading_mode'] = TradingMode.FUTURES + default_conf['margin_mode'] = MarginMode.ISOLATED + + api_mock = MagicMock() + api_mock.fetch_my_trades = MagicMock(return_value=[{ + 'fee': {'cost': None}, + 'price': 3108.65, + 'cost': 0.310865, + 'order': '22255', + 'takerOrMaker': takerormaker, + 'amount': 1, # 1 contract + }]) + exchange = get_patched_exchange(mocker, default_conf, api_mock=api_mock, id='gateio') + exchange._trading_fees = tick + trades = exchange.get_trades_for_order('22255', 'ETH/USDT:USDT', datetime.now(timezone.utc)) + trade = trades[0] + assert trade['fee'] + assert trade['fee']['rate'] == rate + assert trade['fee']['currency'] == 'USDT' + assert trade['fee']['cost'] == cost