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:
commit
d66e6510e3
@ -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,
|
||||||
|
@ -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).
|
||||||
|
@ -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`.
|
||||||
|
@ -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
|
||||||
|
@ -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': {
|
||||||
|
@ -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'])
|
||||||
|
@ -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:
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user