diff --git a/config_full.json.example b/config_full.json.example index a1aabb1b8..357a8f525 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -9,6 +9,7 @@ "trailing_stop": false, "trailing_stop_positive": 0.005, "trailing_stop_positive_offset": 0.0051, + "trailing_only_offset_is_reached": false, "minimal_roi": { "40": 0.0, "30": 0.01, diff --git a/docs/configuration.md b/docs/configuration.md index f1e2ae840..cc874a552 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,7 @@ Mandatory Parameters are marked as **Required**. | `trailing_stop` | false | Enables trailing stop-loss (based on `stoploss` in either configuration or strategy file). More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-strategy). | `trailing_stop_positive` | 0 | Changes stop-loss once profit has been reached. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-strategy). | `trailing_stop_positive_offset` | 0 | Offset on when to apply `trailing_stop_positive`. Percentage value which should be positive. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-strategy). +| `trailing_only_offset_is_reached` | false | Only apply trailing stoploss when the offset is reached. [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-strategy). | `unfilledtimeout.buy` | 10 | **Required.** How long (in minutes) the bot will wait for an unfilled buy order to complete, after which the order will be cancelled. | `unfilledtimeout.sell` | 10 | **Required.** How long (in minutes) the bot will wait for an unfilled sell order to complete, after which the order will be cancelled. | `bid_strategy.ask_last_balance` | 0.0 | **Required.** Set the bidding price. More information [below](#understand-ask_last_balance). diff --git a/docs/stoploss.md b/docs/stoploss.md index 0726aebbc..cbe4fd3c4 100644 --- a/docs/stoploss.md +++ b/docs/stoploss.md @@ -55,8 +55,11 @@ Both values can be configured in the main configuration file and requires `"trai ``` json "trailing_stop_positive": 0.01, "trailing_stop_positive_offset": 0.011, + "trailing_only_offset_is_reached": false ``` The 0.01 would translate to a 1% stop loss, once you hit 1.1% profit. You should also make sure to have this value (`trailing_stop_positive_offset`) lower than your minimal ROI, otherwise minimal ROI will apply first and sell your trade. + +If `"trailing_only_offset_is_reached": true` then the trailing stoploss is only activated once the offset is reached. Until then, the stoploss remains at the configured`stoploss`. diff --git a/freqtrade/configuration.py b/freqtrade/configuration.py index e96305993..d98b2ba21 100644 --- a/freqtrade/configuration.py +++ b/freqtrade/configuration.py @@ -58,7 +58,8 @@ class Configuration(object): config['internals'] = {} logger.info('Validating configuration ...') - self._validate_config(config) + self._validate_config_schema(config) + self._validate_config_consistency(config) # Set strategy if not specified in config and or if it's non default if self.args.strategy != constants.DEFAULT_STRATEGY or not config.get('strategy'): @@ -291,7 +292,7 @@ class Configuration(object): return config - def _validate_config(self, conf: Dict[str, Any]) -> Dict[str, Any]: + def _validate_config_schema(self, conf: Dict[str, Any]) -> Dict[str, Any]: """ Validate the configuration follow the Config Schema :param conf: Config in JSON format @@ -309,6 +310,35 @@ class Configuration(object): best_match(Draft4Validator(constants.CONF_SCHEMA).iter_errors(conf)).message ) + def _validate_config_consistency(self, conf: Dict[str, Any]) -> None: + """ + Validate the configuration consistency + :param conf: Config in JSON format + :return: Returns None if everything is ok, otherwise throw an OperationalException + """ + + # validating trailing stoploss + self._validate_trailing_stoploss(conf) + + def _validate_trailing_stoploss(self, conf: Dict[str, Any]) -> None: + # Skip if trailing stoploss is not activated + if not conf.get('trailing_stop', False): + return + + tsl_positive = float(conf.get('trailing_stop_positive', 0)) + tsl_offset = float(conf.get('trailing_stop_positive_offset', 0)) + tsl_only_offset = conf.get('trailing_only_offset_is_reached', False) + + if tsl_only_offset: + if tsl_positive == 0.0: + raise OperationalException( + f'The config trailing_only_offset_is_reached needs ' + 'trailing_stop_positive_offset to be more than 0 in your config.') + if tsl_positive > 0 and 0 < tsl_offset <= tsl_positive: + raise OperationalException( + f'The config trailing_stop_positive_offset needs ' + 'to be greater than trailing_stop_positive_offset in your config.') + def get_config(self) -> Dict[str, Any]: """ Return the config. Use this method to get the bot config diff --git a/freqtrade/constants.py b/freqtrade/constants.py index e3c059ae0..39b615cc3 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -73,6 +73,7 @@ CONF_SCHEMA = { 'trailing_stop': {'type': 'boolean'}, 'trailing_stop_positive': {'type': 'number', 'minimum': 0, 'maximum': 1}, 'trailing_stop_positive_offset': {'type': 'number', 'minimum': 0, 'maximum': 1}, + 'trailing_only_offset_is_reached': {'type': 'boolean'}, 'unfilledtimeout': { 'type': 'object', 'properties': { diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index c6b3a3796..33f62f2f7 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -118,6 +118,7 @@ class Exchange(object): self.validate_pairs(config['exchange']['pair_whitelist']) self.validate_ordertypes(config.get('order_types', {})) self.validate_order_time_in_force(config.get('order_time_in_force', {})) + if config.get('ticker_interval'): # Check if timeframe is available self.validate_timeframes(config['ticker_interval']) diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index 9d9d59a4a..cece5a5d1 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -46,18 +46,19 @@ class StrategyResolver(IResolver): # Set attributes # Check if we need to override configuration # (Attribute name, default, experimental) - attributes = [("minimal_roi", {"0": 10.0}, False), - ("ticker_interval", None, False), - ("stoploss", None, False), - ("trailing_stop", None, False), - ("trailing_stop_positive", None, False), - ("trailing_stop_positive_offset", 0.0, False), - ("process_only_new_candles", None, False), - ("order_types", None, False), - ("order_time_in_force", None, False), - ("use_sell_signal", False, True), - ("sell_profit_only", False, True), - ("ignore_roi_if_buy_signal", False, True), + attributes = [("minimal_roi", {"0": 10.0}, False), + ("ticker_interval", None, False), + ("stoploss", None, False), + ("trailing_stop", None, False), + ("trailing_stop_positive", None, False), + ("trailing_stop_positive_offset", 0.0, False), + ("trailing_only_offset_is_reached", None, False), + ("process_only_new_candles", None, False), + ("order_types", None, False), + ("order_time_in_force", None, False), + ("use_sell_signal", False, True), + ("sell_profit_only", False, True), + ("ignore_roi_if_buy_signal", False, True), ] for attribute, default, experimental in attributes: if experimental: diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 1d6147357..41dcb8c57 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -73,6 +73,7 @@ class IStrategy(ABC): trailing_stop: bool = False trailing_stop_positive: float trailing_stop_positive_offset: float + trailing_only_offset_is_reached = False # associated ticker interval ticker_interval: str @@ -331,7 +332,11 @@ class IStrategy(ABC): f"with offset {sl_offset:.4g} " f"since we have profit {current_profit:.4f}%") - trade.adjust_stop_loss(current_rate, stop_loss_value) + # if trailing_only_offset_is_reached is true, + # we update trailing stoploss only if offset is reached. + tsl_only_offset = self.config.get('trailing_only_offset_is_reached', False) + if not (tsl_only_offset and current_profit < sl_offset): + trade.adjust_stop_loss(current_rate, stop_loss_value) return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE) diff --git a/freqtrade/tests/test_configuration.py b/freqtrade/tests/test_configuration.py index 51098baaa..21547d205 100644 --- a/freqtrade/tests/test_configuration.py +++ b/freqtrade/tests/test_configuration.py @@ -22,7 +22,7 @@ def test_load_config_invalid_pair(default_conf) -> None: with pytest.raises(ValidationError, match=r'.*does not match.*'): configuration = Configuration(Namespace()) - configuration._validate_config(default_conf) + configuration._validate_config_schema(default_conf) def test_load_config_missing_attributes(default_conf) -> None: @@ -30,7 +30,7 @@ def test_load_config_missing_attributes(default_conf) -> None: with pytest.raises(ValidationError, match=r'.*\'exchange\' is a required property.*'): configuration = Configuration(Namespace()) - configuration._validate_config(default_conf) + configuration._validate_config_schema(default_conf) def test_load_config_incorrect_stake_amount(default_conf) -> None: @@ -38,7 +38,7 @@ def test_load_config_incorrect_stake_amount(default_conf) -> None: with pytest.raises(ValidationError, match=r'.*\'fake\' does not match \'unlimited\'.*'): configuration = Configuration(Namespace()) - configuration._validate_config(default_conf) + configuration._validate_config_schema(default_conf) def test_load_config_file(default_conf, mocker, caplog) -> None: @@ -573,3 +573,29 @@ def test__create_datadir(mocker, default_conf, caplog) -> None: cfg._create_datadir(default_conf, '/foo/bar') assert md.call_args[0][0] == "/foo/bar" assert log_has('Created data directory: /foo/bar', caplog.record_tuples) + + +def test_validate_tsl(default_conf): + default_conf['trailing_stop'] = True + default_conf['trailing_stop_positive'] = 0 + default_conf['trailing_stop_positive_offset'] = 0 + + default_conf['trailing_only_offset_is_reached'] = True + with pytest.raises(OperationalException, + match=r'The config trailing_only_offset_is_reached needs ' + 'trailing_stop_positive_offset to be more than 0 in your config.'): + configuration = Configuration(Namespace()) + configuration._validate_config_consistency(default_conf) + + default_conf['trailing_stop_positive_offset'] = 0.01 + default_conf['trailing_stop_positive'] = 0.015 + with pytest.raises(OperationalException, + match=r'The config trailing_stop_positive_offset needs ' + 'to be greater than trailing_stop_positive_offset in your config.'): + configuration = Configuration(Namespace()) + configuration._validate_config_consistency(default_conf) + + default_conf['trailing_stop_positive'] = 0.01 + default_conf['trailing_stop_positive_offset'] = 0.015 + Configuration(Namespace()) + configuration._validate_config_consistency(default_conf) diff --git a/freqtrade/tests/test_freqtradebot.py b/freqtrade/tests/test_freqtradebot.py index c8e45b7e9..fc7c48663 100644 --- a/freqtrade/tests/test_freqtradebot.py +++ b/freqtrade/tests/test_freqtradebot.py @@ -2488,6 +2488,72 @@ def test_trailing_stop_loss_offset(default_conf, limit_buy_order, fee, assert trade.sell_reason == SellType.TRAILING_STOP_LOSS.value +def test_tsl_only_offset_reached(default_conf, limit_buy_order, fee, + caplog, mocker, markets) -> None: + buy_price = limit_buy_order['price'] + # buy_price: 0.00001099 + + patch_RPCManager(mocker) + patch_exchange(mocker) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_ticker=MagicMock(return_value={ + 'bid': buy_price, + 'ask': buy_price, + 'last': buy_price + }), + buy=MagicMock(return_value={'id': limit_buy_order['id']}), + get_fee=fee, + markets=PropertyMock(return_value=markets), + ) + + default_conf['trailing_stop'] = True + default_conf['trailing_stop_positive'] = 0.05 + default_conf['trailing_stop_positive_offset'] = 0.055 + default_conf['trailing_only_offset_is_reached'] = True + + freqtrade = FreqtradeBot(default_conf) + patch_get_signal(freqtrade) + freqtrade.strategy.min_roi_reached = MagicMock(return_value=False) + freqtrade.create_trade() + + trade = Trade.query.first() + trade.update(limit_buy_order) + caplog.set_level(logging.DEBUG) + # stop-loss not reached + assert freqtrade.handle_trade(trade) is False + assert trade.stop_loss == 0.0000098910 + + # Raise ticker above buy price + mocker.patch('freqtrade.exchange.Exchange.get_ticker', + MagicMock(return_value={ + 'bid': buy_price + 0.0000004, + 'ask': buy_price + 0.0000004, + 'last': buy_price + 0.0000004 + })) + + # stop-loss should not be adjusted as offset is not reached yet + assert freqtrade.handle_trade(trade) is False + + assert not log_has(f'adjusted stop loss', caplog.record_tuples) + assert trade.stop_loss == 0.0000098910 + + # price rises above the offset (rises 12% when the offset is 5.5%) + mocker.patch('freqtrade.exchange.Exchange.get_ticker', + MagicMock(return_value={ + 'bid': buy_price + 0.0000014, + 'ask': buy_price + 0.0000014, + 'last': buy_price + 0.0000014 + })) + + assert freqtrade.handle_trade(trade) is False + assert log_has(f'using positive stop loss mode: 0.05 with offset 0.055 ' + f'since we have profit 0.1218%', + caplog.record_tuples) + assert log_has(f'adjusted stop loss', caplog.record_tuples) + assert trade.stop_loss == 0.0000117705 + + def test_disable_ignore_roi_if_buy_signal(default_conf, limit_buy_order, fee, markets, mocker) -> None: patch_RPCManager(mocker)