Repository: freqtrade/freqtrade

Issue:
Make stoploss as dynamic as roi
My suggestion is to change `stoploss` from float to a dictionary so it can be programmed to self-adjust in dwindling trades. The goal of this change is to give more flexibility to strategies and to reflect time passed in it. For example low timeframe strategies would have a stoploss that tightens with time to decrease risk, as the trade would be out of its planned timeframe.

Example:

```
stoploss = {

        "0": 0.20,
        "10": 0.15,
        "20": 0.10,
        "30": 0.05,
}
```


Discussion:
I'm not certain if this is really a good approach...
It sounds like a simple option as we have it for ROI - but if you consider that trailing-stoploss is another option - i think the 2 would be conflicting (and people would expect different things depending on their needs).

```
stoploss = {

        "0": 0.10,
        "60": 0.07,
        "120": 0.05,
        "180": 0.02,
}
trailing_stop = True
```

Assume the  above configuration  - but in combination with trailing stop (I'm intentionally leaving out the different "positive" options for now).

Now what would this mean? 

Let's assume an entry-price of 100 USDT (for simplicity...).

It trails for the first hour at -10% (`stoploss=90USDT`).
Now assume it did did raise 2% in the first hour to 102 USDT - so stoploss is now at 92 USDT (-8% of the initial value - trailing at -10% of current price).

The configured stoploss "jumps" to 7% after the hour.

should it then immediately sell as it's below 8% (92USDT) from the initial price (100 USDT)? 
or should the configured 7% be subtracted from the current price (102USDT) (so it's now trailing at 7% (and stoploss would now be at ~95 USDT) ?



If you now also add in `trailing_stop_positive` and `trailing_stop_positive_offset`, then already thinking through how it should behave becomes difficult - as it'll depend on the user expectation as to what the desired outcome would be. 

* Maybe i wanna trail it and pull the trailing stoploss closer as it raises?
* But maybe i wanna pull the stoploss closer only if it's not raising ... but keep it at a static X% below current price if it's raising ...
* maybe i want to disable time-based options once it starts trailing ... ? 

---


Currently i don't see any way to make this possible in a way users will understand and be able to configure easily - not without utterly confusing people as to how stoploss works in combination with trailing stop, trailing stop possitive, ... etc. 

It's easy as long as it's a static stoploss - but very complex with trailing stoploss, as that has very many options...
additionally, it may be difficult to manage it with stoploss on exchange...

but the idea seems to be interesting for me,
need to study the objections by @xmatthias in more detail though
well - mainly my objection is that it's conflicting ...
as long as trailing_stop is inactive, it'll be ok - but once you enable that it becomes highly confusing - as you have 2 (eventually conflicting) logics working on the same, single value.

stoploss on exchange is not a problem - that's just similar to trailing stop - cancel and recreate after every change of interval (that's the smallest of all problems) 
But isn't roi interacting with the trailing stoploss in the same manner? It could be disabled by default, just as roi is set to one element in most cases. Only those who need it could use it, others would simply:

```
stoploss = {
        "0": 0.10
}
```

I also suggested because I thought this would be easy to add as it exists in roi. The problem with the trailing stoploss is that it is trigger-based, not time-based. This would mostly serve to cut off trades that are lingering for too long in loss with a specified range. However, not needed as roi can be set to negative also (although that's not statistically sound).
> But isn't roi interacting with the trailing stoploss in the same manner?

no - it's not that simple i'm afraid.

ROI looks at the positive side - so an ROI of 0.1 will look if profit is > 10%.
for ROI, it's clear that it's calculated from the open_price.

for stoploss, it's not so clear as it can have different options.
You MUST calculate stoploss from open_price (when it's static).
You MUST calculate stoploss from the maximum reached price for trailing stoploss.

```
stoploss = -0.05
```

Just the above configuration can have 2 different theoretical values (depending if trailing_stop is True or False.
It'll be 95 USDT (and stay there for static stoploss) - but it can be anything > 95UDST for trailing stoploss.


Just cutting trades when they stay open too long can be done with `minimal_roi={"60": -1}`. 

As an addition to this, I was thinking about setting the stoploss as a number instead of a percentage. In my case, the lowest value of the second to last candle.

`stoploss = dataframe.iloc[-2]['close']`

At the moment this is not possible, but I could rewrite to a percentage: 
`stoploss = (dataframe.iloc[-2]['low']-dataframe.iloc[-1]['close'])/(dataframe.iloc[-1]['close'])`

Can someone tell me in which file 'stoploss' is defined, to resolve this error:
`NameError: name 'dataframe' is not defined`
@Timmeeehhh you can't change stoploss per pair at the moment, sorry.
I have a suggestion.
After purchasing;
If the price makes 5% profit: Trailing Stop 1%
If the price makes 10% profit: Trailing Stop 3%
If the price makes a 50% profit: Trailing Stop 10%
If the price makes 100% profit: Trailing Stop 15%

```
stoploss = {

         "0.05%": 0.01,
         "0.10%": 0.03,
         "0.50%": 0.10,
         "1%": 0.15,
}
```
and that's the reason why i think this is a bad idea (as pointed out above).

the initial proposal assumes this to be a structure similar to roi - where it's `<min>: <value>`.

Now for trailing stop, you'd need it as `<profit>:<offset>`.

However, maybe someone else would like to combine these two - which would make this rather complex to configure (and mainly, errorprone for non-experienced users):

trail with 0.5% if 1% profit in the first hour, then trail with 0.2% if 1% profit, or with 0.3 if 2% profit ... (see what i mean?)



The structure to even configure this will become very complex very soon (due to the many options we support in regards to stoploss/trailingstop/trailingstopwithoffsetonly  

... so i strongly doubt that we'll implement anything like this in the near future, as the combination of the already existing options with a new way to configure stoploss will multiply itself from a configuration option point of view.

Resolving patch:
diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py
--- a/freqtrade/persistence/models.py
+++ b/freqtrade/persistence/models.py
@@ -342,6 +342,12 @@ def adjust_min_max_rates(self, current_price: float) -> None:
         self.max_rate = max(current_price, self.max_rate or self.open_rate)
         self.min_rate = min(current_price, self.min_rate or self.open_rate)
 
+    def _set_new_stoploss(self, new_loss: float, stoploss: float):
+        """Assign new stop value"""
+        self.stop_loss = new_loss
+        self.stop_loss_pct = -1 * abs(stoploss)
+        self.stoploss_last_update = datetime.utcnow()
+
     def adjust_stop_loss(self, current_price: float, stoploss: float,
                          initial: bool = False) -> None:
         """
@@ -360,19 +366,15 @@ def adjust_stop_loss(self, current_price: float, stoploss: float,
         # no stop loss assigned yet
         if not self.stop_loss:
             logger.debug(f"{self.pair} - Assigning new stoploss...")
-            self.stop_loss = new_loss
-            self.stop_loss_pct = -1 * abs(stoploss)
+            self._set_new_stoploss(new_loss, stoploss)
             self.initial_stop_loss = new_loss
             self.initial_stop_loss_pct = -1 * abs(stoploss)
-            self.stoploss_last_update = datetime.utcnow()
 
         # evaluate if the stop loss needs to be updated
         else:
             if new_loss > self.stop_loss:  # stop losses only walk up, never down!
                 logger.debug(f"{self.pair} - Adjusting stoploss...")
-                self.stop_loss = new_loss
-                self.stop_loss_pct = -1 * abs(stoploss)
-                self.stoploss_last_update = datetime.utcnow()
+                self._set_new_stoploss(new_loss, stoploss)
             else:
                 logger.debug(f"{self.pair} - Keeping current stoploss...")
 
diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py
--- a/freqtrade/strategy/interface.py
+++ b/freqtrade/strategy/interface.py
@@ -89,6 +89,7 @@ class IStrategy(ABC):
     trailing_stop_positive: Optional[float] = None
     trailing_stop_positive_offset: float = 0.0
     trailing_only_offset_is_reached = False
+    use_custom_stoploss: bool = False
 
     # associated timeframe
     ticker_interval: str  # DEPRECATED
@@ -254,6 +255,28 @@ def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: f
         """
         return True
 
+    def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
+                        current_profit: float, **kwargs) -> float:
+        """
+        Custom stoploss logic, returning the new distance relative to current_rate (as ratio).
+        e.g. returning -0.05 would create a stoploss 5% below current_rate.
+        The custom stoploss can never be below self.stoploss, which serves as a hard maximum loss.
+
+        For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
+
+        When not implemented by a strategy, returns the initial stoploss value
+        Only called when use_custom_stoploss is set to True.
+
+        :param pair: Pair that's currently analyzed
+        :param trade: trade object.
+        :param current_time: datetime object, containing the current datetime
+        :param current_rate: Rate, calculated based on pricing settings in ask_strategy.
+        :param current_profit: Current profit (as ratio), calculated based on current_rate.
+        :param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
+        :return float: New stoploss value, relative to the currentrate
+        """
+        return self.stoploss
+
     def informative_pairs(self) -> ListPairsWithTimeframes:
         """
         Define additional, informative pair/interval combinations to be cached from the exchange.
@@ -531,6 +554,19 @@ def stop_loss_reached(self, current_rate: float, trade: Trade,
         # Initiate stoploss with open_rate. Does nothing if stoploss is already set.
         trade.adjust_stop_loss(trade.open_rate, stop_loss_value, initial=True)
 
+        if self.use_custom_stoploss:
+            stop_loss_value = strategy_safe_wrapper(self.custom_stoploss, default_retval=None
+                                                    )(pair=trade.pair, trade=trade,
+                                                      current_time=current_time,
+                                                      current_rate=current_rate,
+                                                      current_profit=current_profit)
+            # Sanity check - error cases will return None
+            if stop_loss_value:
+                # logger.info(f"{trade.pair} {stop_loss_value=} {current_profit=}")
+                trade.adjust_stop_loss(current_rate, stop_loss_value)
+            else:
+                logger.warning("CustomStoploss function did not return valid stoploss")
+
         if self.trailing_stop:
             # trailing stoploss handling
             sl_offset = self.trailing_stop_positive_offset


Test patch:
diff --git a/tests/strategy/test_default_strategy.py b/tests/strategy/test_default_strategy.py
--- a/tests/strategy/test_default_strategy.py
+++ b/tests/strategy/test_default_strategy.py
@@ -1,5 +1,9 @@
+from datetime import datetime
+
 from pandas import DataFrame
 
+from freqtrade.persistence.models import Trade
+
 from .strats.default_strategy import DefaultStrategy
 
 
@@ -12,7 +16,7 @@ def test_default_strategy_structure():
     assert hasattr(DefaultStrategy, 'populate_sell_trend')
 
 
-def test_default_strategy(result):
+def test_default_strategy(result, fee):
     strategy = DefaultStrategy({})
 
     metadata = {'pair': 'ETH/BTC'}
@@ -23,3 +27,18 @@ def test_default_strategy(result):
     assert type(indicators) is DataFrame
     assert type(strategy.populate_buy_trend(indicators, metadata)) is DataFrame
     assert type(strategy.populate_sell_trend(indicators, metadata)) is DataFrame
+
+    trade = Trade(
+        open_rate=19_000,
+        amount=0.1,
+        pair='ETH/BTC',
+        fee_open=fee.return_value
+    )
+
+    assert strategy.confirm_trade_entry(pair='ETH/BTC', order_type='limit', amount=0.1,
+                                        rate=20000, time_in_force='gtc') is True
+    assert strategy.confirm_trade_exit(pair='ETH/BTC', trade=trade, order_type='limit', amount=0.1,
+                                       rate=20000, time_in_force='gtc', sell_reason='roi') is True
+
+    assert strategy.custom_stoploss(pair='ETH/BTC', trade=trade, current_time=datetime.now(),
+                                    current_rate=20_000, current_profit=0.05) == strategy.stoploss
diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py
--- a/tests/strategy/test_interface.py
+++ b/tests/strategy/test_interface.py
@@ -13,6 +13,7 @@
 from freqtrade.exceptions import StrategyError
 from freqtrade.persistence import PairLocks, Trade
 from freqtrade.resolvers import StrategyResolver
+from freqtrade.strategy.interface import SellCheckTuple, SellType
 from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
 from tests.conftest import log_has, log_has_re
 
@@ -105,9 +106,7 @@ def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history):
     assert log_has('Outdated history for pair xyz. Last tick is 16 minutes old', caplog)
 
 
-def test_assert_df_raise(default_conf, mocker, caplog, ohlcv_history):
-    # default_conf defines a 5m interval. we check interval * 2 + 5m
-    # this is necessary as the last candle is removed (partial candles) by default
+def test_assert_df_raise(mocker, caplog, ohlcv_history):
     ohlcv_history.loc[1, 'date'] = arrow.utcnow().shift(minutes=-16)
     # Take a copy to correctly modify the call
     mocked_history = ohlcv_history.copy()
@@ -127,7 +126,7 @@ def test_assert_df_raise(default_conf, mocker, caplog, ohlcv_history):
                    caplog)
 
 
-def test_assert_df(default_conf, mocker, ohlcv_history, caplog):
+def test_assert_df(ohlcv_history, caplog):
     df_len = len(ohlcv_history) - 1
     # Ensure it's running when passed correctly
     _STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
@@ -288,6 +287,77 @@ def test_min_roi_reached3(default_conf, fee) -> None:
     assert strategy.min_roi_reached(trade, 0.31, arrow.utcnow().shift(minutes=-2).datetime)
 
 
+@pytest.mark.parametrize(
+    'profit,adjusted,expected,trailing,custom,profit2,adjusted2,expected2,custom_stop', [
+        # Profit, adjusted stoploss(absolute), profit for 2nd call, enable trailing,
+        #   enable custom stoploss, expected after 1st call, expected after 2nd call
+        (0.2, 0.9, SellType.NONE, False, False, 0.3, 0.9, SellType.NONE, None),
+        (0.2, 0.9, SellType.NONE, False, False, -0.2, 0.9, SellType.STOP_LOSS, None),
+        (0.2, 1.14, SellType.NONE, True, False, 0.05, 1.14, SellType.TRAILING_STOP_LOSS, None),
+        (0.01, 0.96, SellType.NONE, True, False, 0.05, 1, SellType.NONE, None),
+        (0.05, 1, SellType.NONE, True, False, -0.01, 1, SellType.TRAILING_STOP_LOSS, None),
+        # Default custom case - trails with 10%
+        (0.05, 0.95, SellType.NONE, False, True, -0.02, 0.95, SellType.NONE, None),
+        (0.05, 0.95, SellType.NONE, False, True, -0.06, 0.95, SellType.TRAILING_STOP_LOSS, None),
+        (0.05, 1, SellType.NONE, False, True, -0.06, 1, SellType.TRAILING_STOP_LOSS,
+         lambda **kwargs: -0.05),
+        (0.05, 1, SellType.NONE, False, True, 0.09, 1.04, SellType.NONE,
+         lambda **kwargs: -0.05),
+        (0.05, 0.95, SellType.NONE, False, True, 0.09, 0.98, SellType.NONE,
+         lambda current_profit, **kwargs: -0.1 if current_profit < 0.6 else -(current_profit * 2)),
+        # Error case - static stoploss in place
+        (0.05, 0.9, SellType.NONE, False, True, 0.09, 0.9, SellType.NONE,
+         lambda **kwargs: None),
+    ])
+def test_stop_loss_reached(default_conf, fee, profit, adjusted, expected, trailing, custom,
+                           profit2, adjusted2, expected2, custom_stop) -> None:
+
+    default_conf.update({'strategy': 'DefaultStrategy'})
+
+    strategy = StrategyResolver.load_strategy(default_conf)
+    trade = Trade(
+        pair='ETH/BTC',
+        stake_amount=0.01,
+        amount=1,
+        open_date=arrow.utcnow().shift(hours=-1).datetime,
+        fee_open=fee.return_value,
+        fee_close=fee.return_value,
+        exchange='bittrex',
+        open_rate=1,
+    )
+    trade.adjust_min_max_rates(trade.open_rate)
+    strategy.trailing_stop = trailing
+    strategy.trailing_stop_positive = -0.05
+    strategy.use_custom_stoploss = custom
+    original_stopvalue = strategy.custom_stoploss
+    if custom_stop:
+        strategy.custom_stoploss = custom_stop
+
+    now = arrow.utcnow().datetime
+    sl_flag = strategy.stop_loss_reached(current_rate=trade.open_rate * (1 + profit), trade=trade,
+                                         current_time=now, current_profit=profit,
+                                         force_stoploss=0, high=None)
+    assert isinstance(sl_flag, SellCheckTuple)
+    assert sl_flag.sell_type == expected
+    if expected == SellType.NONE:
+        assert sl_flag.sell_flag is False
+    else:
+        assert sl_flag.sell_flag is True
+    assert round(trade.stop_loss, 2) == adjusted
+
+    sl_flag = strategy.stop_loss_reached(current_rate=trade.open_rate * (1 + profit2), trade=trade,
+                                         current_time=now, current_profit=profit2,
+                                         force_stoploss=0, high=None)
+    assert sl_flag.sell_type == expected2
+    if expected2 == SellType.NONE:
+        assert sl_flag.sell_flag is False
+    else:
+        assert sl_flag.sell_flag is True
+    assert round(trade.stop_loss, 2) == adjusted2
+
+    strategy.custom_stoploss = original_stopvalue
+
+
 def test_analyze_ticker_default(ohlcv_history, mocker, caplog) -> None:
     caplog.set_level(logging.DEBUG)
     ind_mock = MagicMock(side_effect=lambda x, meta: x)
diff --git a/tests/strategy/test_strategy.py b/tests/strategy/test_strategy_loading.py
similarity index 100%
rename from tests/strategy/test_strategy.py
rename to tests/strategy/test_strategy_loading.py