diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index b60957b58..64e5b76ea 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -60,6 +60,7 @@ "force_entry": "market", "stoploss": "market", "stoploss_on_exchange": false, + "stoploss_price_type": "last", "stoploss_on_exchange_interval": 60, "stoploss_on_exchange_limit_ratio": 0.99 }, diff --git a/docs/stoploss.md b/docs/stoploss.md index 20e53d8f5..1182b6d90 100644 --- a/docs/stoploss.md +++ b/docs/stoploss.md @@ -52,6 +52,17 @@ The bot cannot do these every 5 seconds (at each iteration), otherwise it would So this parameter will tell the bot how often it should update the stoploss order. The default value is 60 (1 minute). This same logic will reapply a stoploss order on the exchange should you cancel it accidentally. +### stoploss_price_type + +!!! Warning "Only applies to futures" + `stoploss_price_type` only applies to futures markets (on exchanges where it's available). + Freqtrade will perform a validation of this setting on startup, failing to start if an invalid setting for your exchange has been selected. + +Stoploss on exchange on futures markets can trigger on different price types. +The naming for these prices in exchange terminology often varies, but is usually something around "last" (or "contract price" ), "mark" and "index". + +Acceptable values for this setting are `"last"`, `"mark"` and `"index"` - which freqtrade will transfer automatically to the corresponding API type, and place the [stoploss on exchange](#stoploss_on_exchange-and-stoploss_on_exchange_limit_ratio) order correspondingly. + ### force_exit `force_exit` is an optional value, which defaults to the same value as `exit` and is used when sending a `/forceexit` command from Telegram or from the Rest API. diff --git a/freqtrade/constants.py b/freqtrade/constants.py index b41a3ad9c..08048c3e7 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -5,7 +5,7 @@ bot constants """ from typing import Any, Dict, List, Literal, Tuple -from freqtrade.enums import CandleType, RPCMessageType +from freqtrade.enums import CandleType, PriceType, RPCMessageType DEFAULT_CONFIG = 'config.json' @@ -25,6 +25,7 @@ PRICING_SIDES = ['ask', 'bid', 'same', 'other'] ORDERTYPE_POSSIBILITIES = ['limit', 'market'] _ORDERTIF_POSSIBILITIES = ['GTC', 'FOK', 'IOC', 'PO'] ORDERTIF_POSSIBILITIES = _ORDERTIF_POSSIBILITIES + [t.lower() for t in _ORDERTIF_POSSIBILITIES] +STOPLOSS_PRICE_TYPES = [p for p in PriceType] HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss', 'SharpeHyperOptLoss', 'SharpeHyperOptLossDaily', 'SortinoHyperOptLoss', 'SortinoHyperOptLossDaily', @@ -229,6 +230,7 @@ CONF_SCHEMA = { 'default': 'market'}, 'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, 'stoploss_on_exchange': {'type': 'boolean'}, + 'stoploss_price_type': {'type': 'string', 'enum': STOPLOSS_PRICE_TYPES}, 'stoploss_on_exchange_interval': {'type': 'number'}, 'stoploss_on_exchange_limit_ratio': {'type': 'number', 'minimum': 0.0, 'maximum': 1.0} diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index eb70a2894..8ef53e12d 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -6,6 +6,7 @@ from freqtrade.enums.exittype import ExitType from freqtrade.enums.hyperoptstate import HyperoptState from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.ordertypevalue import OrderTypeValues +from freqtrade.enums.pricetype import PriceType from freqtrade.enums.rpcmessagetype import NO_ECHO_MESSAGES, RPCMessageType, RPCRequestType from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode from freqtrade.enums.signaltype import SignalDirection, SignalTagType, SignalType diff --git a/freqtrade/enums/pricetype.py b/freqtrade/enums/pricetype.py new file mode 100644 index 000000000..bf0922b9f --- /dev/null +++ b/freqtrade/enums/pricetype.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class PriceType(str, Enum): + """Enum to distinguish possible trigger prices for stoplosses""" + LAST = "last" + MARK = "mark" + INDEX = "index" diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index d362a75cd..740d6e8a0 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -7,7 +7,7 @@ from typing import Dict, List, Optional, Tuple import arrow import ccxt -from freqtrade.enums import CandleType, MarginMode, TradingMode +from freqtrade.enums import CandleType, MarginMode, PriceType, TradingMode from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exchange import Exchange from freqtrade.exchange.common import retrier @@ -33,6 +33,11 @@ class Binance(Exchange): "stoploss_order_types": {"limit": "stop", "market": "stop_market"}, "tickers_have_price": False, "floor_leverage": True, + "stop_price_type_field": "workingType", + "stop_price_type_value_mapping": { + PriceType.LAST: "CONTRACT_PRICE", + PriceType.MARK: "MARK_PRICE", + }, } _supported_trading_mode_margin_pairs: List[Tuple[TradingMode, MarginMode]] = [ diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index d0598d8de..c565b891f 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -6,7 +6,7 @@ from typing import Any, Dict, List, Optional, Tuple import ccxt from freqtrade.constants import BuySell -from freqtrade.enums import MarginMode, TradingMode +from freqtrade.enums import MarginMode, PriceType, TradingMode from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exchange import Exchange from freqtrade.exchange.common import retrier @@ -37,6 +37,12 @@ class Bybit(Exchange): "funding_fee_timeframe": "8h", "stoploss_on_exchange": True, "stoploss_order_types": {"limit": "limit", "market": "market"}, + "stop_price_type_field": "triggerBy", + "stop_price_type_value_mapping": { + PriceType.LAST: "LastPrice", + PriceType.MARK: "MarkPrice", + PriceType.INDEX: "IndexPrice", + }, } _supported_trading_mode_margin_pairs: List[Tuple[TradingMode, MarginMode]] = [ diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 5ee51d686..282dbab1c 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -24,6 +24,7 @@ from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHAN PairWithTimeframe) from freqtrade.data.converter import clean_ohlcv_dataframe, ohlcv_to_dataframe, trades_dict_to_list from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode +from freqtrade.enums.pricetype import PriceType from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError, InvalidOrderException, OperationalException, PricingError, RetryableOrderError, TemporaryError) @@ -600,12 +601,27 @@ class Exchange: if not self.exchange_has('createMarketOrder'): raise OperationalException( f'Exchange {self.name} does not support market orders.') + self.validate_stop_ordertypes(order_types) + def validate_stop_ordertypes(self, order_types: Dict) -> None: + """ + Validate stoploss order types + """ if (order_types.get("stoploss_on_exchange") and not self._ft_has.get("stoploss_on_exchange", False)): raise OperationalException( f'On exchange stoploss is not supported for {self.name}.' ) + if self.trading_mode == TradingMode.FUTURES: + price_mapping = self._ft_has.get('stop_price_type_value_mapping', {}).keys() + if ( + order_types.get("stoploss_on_exchange", False) is True + and 'stoploss_price_type' in order_types + and order_types['stoploss_price_type'] not in price_mapping + ): + raise OperationalException( + f'On exchange stoploss price type is not supported for {self.name}.' + ) def validate_pricing(self, pricing: Dict) -> None: if pricing.get('use_order_book', False) and not self.exchange_has('fetchL2OrderBook'): @@ -1161,6 +1177,10 @@ class Exchange: stop_price=stop_price_norm) if self.trading_mode == TradingMode.FUTURES: params['reduceOnly'] = True + if 'stoploss_price_type' in order_types and 'stop_price_type_field' in self._ft_has: + price_type = self._ft_has['stop_price_type_value_mapping'][ + order_types.get('stoploss_price_type', PriceType.LAST)] + params[self._ft_has['stop_price_type_field']] = price_type amount = self.amount_to_precision(pair, self._amount_to_contracts(pair, amount)) diff --git a/freqtrade/exchange/gateio.py b/freqtrade/exchange/gateio.py index de178af02..247e4e954 100644 --- a/freqtrade/exchange/gateio.py +++ b/freqtrade/exchange/gateio.py @@ -34,6 +34,13 @@ class Gateio(Exchange): "needs_trading_fees": True, "fee_cost_in_contracts": False, # Set explicitly to false for clarity "order_props_in_contracts": ['amount', 'filled', 'remaining'], + # TODO: Reenable once https://github.com/ccxt/ccxt/issues/16749 is available + # "stop_price_type_field": "price_type", + # "stop_price_type_value_mapping": { + # PriceType.LAST: 0, + # PriceType.MARK: 1, + # PriceType.INDEX: 2, + # }, } _supported_trading_mode_margin_pairs: List[Tuple[TradingMode, MarginMode]] = [ @@ -49,6 +56,7 @@ class Gateio(Exchange): if any(v == 'market' for k, v in order_types.items()): raise OperationalException( f'Exchange {self.name} does not support market orders.') + super().validate_stop_ordertypes(order_types) def _get_params( self, diff --git a/freqtrade/exchange/okx.py b/freqtrade/exchange/okx.py index 6d05622c4..e7d658d24 100644 --- a/freqtrade/exchange/okx.py +++ b/freqtrade/exchange/okx.py @@ -5,6 +5,7 @@ import ccxt from freqtrade.constants import BuySell from freqtrade.enums import CandleType, MarginMode, TradingMode +from freqtrade.enums.pricetype import PriceType from freqtrade.exceptions import DDosProtection, OperationalException, TemporaryError from freqtrade.exchange import Exchange, date_minus_candles from freqtrade.exchange.common import retrier @@ -27,6 +28,12 @@ class Okx(Exchange): _ft_has_futures: Dict = { "tickers_have_quoteVolume": False, "fee_cost_in_contracts": True, + "stop_price_type_field": "tpTriggerPxType", + "stop_price_type_value_mapping": { + PriceType.LAST: "last", + PriceType.MARK: "index", + PriceType.INDEX: "mark", + }, } _supported_trading_mode_margin_pairs: List[Tuple[TradingMode, MarginMode]] = [ diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 4ccfc7e9c..432747be0 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -50,7 +50,7 @@ def test_stoploss_order_binance(default_conf, mocker, limitratio, expected, side ) api_mock.create_order.reset_mock() - order_types = {'stoploss': 'limit'} + order_types = {'stoploss': 'limit', 'stoploss_price_type': 'mark'} if limitratio is not None: order_types.update({'stoploss_on_exchange_limit_ratio': limitratio}) @@ -75,7 +75,7 @@ def test_stoploss_order_binance(default_conf, mocker, limitratio, expected, side if trademode == TradingMode.SPOT: params_dict = {'stopPrice': 220} else: - params_dict = {'stopPrice': 220, 'reduceOnly': True} + params_dict = {'stopPrice': 220, 'reduceOnly': True, 'workingType': 'MARK_PRICE'} assert api_mock.create_order.call_args_list[0][1]['params'] == params_dict # test exception handling diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 0ebdfd218..90341142a 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1060,6 +1060,47 @@ def test_validate_ordertypes(default_conf, mocker): Exchange(default_conf) +@pytest.mark.parametrize('exchange_name,stopadv, expected', [ + ('binance', 'last', True), + ('binance', 'mark', True), + ('binance', 'index', False), + ('bybit', 'last', True), + ('bybit', 'mark', True), + ('bybit', 'index', True), + # ('okx', 'last', True), + # ('okx', 'mark', True), + # ('okx', 'index', True), + ('gate', 'last', False), + ('gate', 'mark', False), + ('gate', 'index', False), + ]) +def test_validate_ordertypes_stop_advanced(default_conf, mocker, exchange_name, stopadv, expected): + + api_mock = MagicMock() + default_conf['trading_mode'] = TradingMode.FUTURES + default_conf['margin_mode'] = MarginMode.ISOLATED + type(api_mock).has = PropertyMock(return_value={'createMarketOrder': True}) + mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock)) + mocker.patch('freqtrade.exchange.Exchange._load_markets', MagicMock(return_value={})) + mocker.patch('freqtrade.exchange.Exchange.validate_pairs') + mocker.patch('freqtrade.exchange.Exchange.validate_timeframes') + mocker.patch('freqtrade.exchange.Exchange.validate_stakecurrency') + mocker.patch('freqtrade.exchange.Exchange.validate_pricing') + default_conf['order_types'] = { + 'entry': 'limit', + 'exit': 'limit', + 'stoploss': 'limit', + 'stoploss_on_exchange': True, + 'stoploss_price_type': stopadv, + } + if expected: + ExchangeResolver.load_exchange(exchange_name, default_conf) + else: + with pytest.raises(OperationalException, + match=r'On exchange stoploss price type is not supported for .*'): + ExchangeResolver.load_exchange(exchange_name, default_conf) + + def test_validate_order_types_not_in_config(default_conf, mocker): api_mock = MagicMock() mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock)) diff --git a/tests/exchange/test_gateio.py b/tests/exchange/test_gateio.py index dabdbba65..9802063e8 100644 --- a/tests/exchange/test_gateio.py +++ b/tests/exchange/test_gateio.py @@ -18,8 +18,8 @@ def test_validate_order_types_gateio(default_conf, mocker): mocker.patch('freqtrade.exchange.Exchange.validate_timeframes') mocker.patch('freqtrade.exchange.Exchange.validate_stakecurrency') mocker.patch('freqtrade.exchange.Exchange.validate_pricing') - mocker.patch('freqtrade.exchange.Exchange.name', 'Bittrex') - exch = ExchangeResolver.load_exchange('gateio', default_conf, True) + mocker.patch('freqtrade.exchange.Exchange.name', 'Gate') + exch = ExchangeResolver.load_exchange('gate', default_conf, True) assert isinstance(exch, Gateio) default_conf['order_types'] = {