Merge pull request #1645 from mishaker/trailing_only_offset

Adding an option for trailing stoploss: "trailing_only_offset_is_reached"
This commit is contained in:
Matthias 2019-03-16 10:43:56 +01:00 committed by GitHub
commit d66e6510e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 153 additions and 18 deletions

View File

@ -9,6 +9,7 @@
"trailing_stop": false, "trailing_stop": false,
"trailing_stop_positive": 0.005, "trailing_stop_positive": 0.005,
"trailing_stop_positive_offset": 0.0051, "trailing_stop_positive_offset": 0.0051,
"trailing_only_offset_is_reached": false,
"minimal_roi": { "minimal_roi": {
"40": 0.0, "40": 0.0,
"30": 0.01, "30": 0.01,

View File

@ -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` | 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` | 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_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.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. | `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). | `bid_strategy.ask_last_balance` | 0.0 | **Required.** Set the bidding price. More information [below](#understand-ask_last_balance).

View File

@ -55,8 +55,11 @@ Both values can be configured in the main configuration file and requires `"trai
``` json ``` json
"trailing_stop_positive": 0.01, "trailing_stop_positive": 0.01,
"trailing_stop_positive_offset": 0.011, "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. 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. 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`.

View File

@ -58,7 +58,8 @@ class Configuration(object):
config['internals'] = {} config['internals'] = {}
logger.info('Validating configuration ...') 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 # 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'): if self.args.strategy != constants.DEFAULT_STRATEGY or not config.get('strategy'):
@ -291,7 +292,7 @@ class Configuration(object):
return config 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 Validate the configuration follow the Config Schema
:param conf: Config in JSON format :param conf: Config in JSON format
@ -309,6 +310,35 @@ class Configuration(object):
best_match(Draft4Validator(constants.CONF_SCHEMA).iter_errors(conf)).message 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]: def get_config(self) -> Dict[str, Any]:
""" """
Return the config. Use this method to get the bot config Return the config. Use this method to get the bot config

View File

@ -73,6 +73,7 @@ CONF_SCHEMA = {
'trailing_stop': {'type': 'boolean'}, 'trailing_stop': {'type': 'boolean'},
'trailing_stop_positive': {'type': 'number', 'minimum': 0, 'maximum': 1}, 'trailing_stop_positive': {'type': 'number', 'minimum': 0, 'maximum': 1},
'trailing_stop_positive_offset': {'type': 'number', 'minimum': 0, 'maximum': 1}, 'trailing_stop_positive_offset': {'type': 'number', 'minimum': 0, 'maximum': 1},
'trailing_only_offset_is_reached': {'type': 'boolean'},
'unfilledtimeout': { 'unfilledtimeout': {
'type': 'object', 'type': 'object',
'properties': { 'properties': {

View File

@ -118,6 +118,7 @@ class Exchange(object):
self.validate_pairs(config['exchange']['pair_whitelist']) self.validate_pairs(config['exchange']['pair_whitelist'])
self.validate_ordertypes(config.get('order_types', {})) self.validate_ordertypes(config.get('order_types', {}))
self.validate_order_time_in_force(config.get('order_time_in_force', {})) self.validate_order_time_in_force(config.get('order_time_in_force', {}))
if config.get('ticker_interval'): if config.get('ticker_interval'):
# Check if timeframe is available # Check if timeframe is available
self.validate_timeframes(config['ticker_interval']) self.validate_timeframes(config['ticker_interval'])

View File

@ -46,18 +46,19 @@ class StrategyResolver(IResolver):
# Set attributes # Set attributes
# Check if we need to override configuration # Check if we need to override configuration
# (Attribute name, default, experimental) # (Attribute name, default, experimental)
attributes = [("minimal_roi", {"0": 10.0}, False), attributes = [("minimal_roi", {"0": 10.0}, False),
("ticker_interval", None, False), ("ticker_interval", None, False),
("stoploss", None, False), ("stoploss", None, False),
("trailing_stop", None, False), ("trailing_stop", None, False),
("trailing_stop_positive", None, False), ("trailing_stop_positive", None, False),
("trailing_stop_positive_offset", 0.0, False), ("trailing_stop_positive_offset", 0.0, False),
("process_only_new_candles", None, False), ("trailing_only_offset_is_reached", None, False),
("order_types", None, False), ("process_only_new_candles", None, False),
("order_time_in_force", None, False), ("order_types", None, False),
("use_sell_signal", False, True), ("order_time_in_force", None, False),
("sell_profit_only", False, True), ("use_sell_signal", False, True),
("ignore_roi_if_buy_signal", False, True), ("sell_profit_only", False, True),
("ignore_roi_if_buy_signal", False, True),
] ]
for attribute, default, experimental in attributes: for attribute, default, experimental in attributes:
if experimental: if experimental:

View File

@ -73,6 +73,7 @@ class IStrategy(ABC):
trailing_stop: bool = False trailing_stop: bool = False
trailing_stop_positive: float trailing_stop_positive: float
trailing_stop_positive_offset: float trailing_stop_positive_offset: float
trailing_only_offset_is_reached = False
# associated ticker interval # associated ticker interval
ticker_interval: str ticker_interval: str
@ -331,7 +332,11 @@ class IStrategy(ABC):
f"with offset {sl_offset:.4g} " f"with offset {sl_offset:.4g} "
f"since we have profit {current_profit:.4f}%") 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) return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)

View File

@ -22,7 +22,7 @@ def test_load_config_invalid_pair(default_conf) -> None:
with pytest.raises(ValidationError, match=r'.*does not match.*'): with pytest.raises(ValidationError, match=r'.*does not match.*'):
configuration = Configuration(Namespace()) configuration = Configuration(Namespace())
configuration._validate_config(default_conf) configuration._validate_config_schema(default_conf)
def test_load_config_missing_attributes(default_conf) -> None: 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.*'): with pytest.raises(ValidationError, match=r'.*\'exchange\' is a required property.*'):
configuration = Configuration(Namespace()) configuration = Configuration(Namespace())
configuration._validate_config(default_conf) configuration._validate_config_schema(default_conf)
def test_load_config_incorrect_stake_amount(default_conf) -> None: 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\'.*'): with pytest.raises(ValidationError, match=r'.*\'fake\' does not match \'unlimited\'.*'):
configuration = Configuration(Namespace()) configuration = Configuration(Namespace())
configuration._validate_config(default_conf) configuration._validate_config_schema(default_conf)
def test_load_config_file(default_conf, mocker, caplog) -> None: 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') cfg._create_datadir(default_conf, '/foo/bar')
assert md.call_args[0][0] == "/foo/bar" assert md.call_args[0][0] == "/foo/bar"
assert log_has('Created data directory: /foo/bar', caplog.record_tuples) 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)

View File

@ -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 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, def test_disable_ignore_roi_if_buy_signal(default_conf, limit_buy_order,
fee, markets, mocker) -> None: fee, markets, mocker) -> None:
patch_RPCManager(mocker) patch_RPCManager(mocker)