Repository: freqtrade/freqtrade

Issue:
Exit after n minutes/n candles and roi = 0
Hi there,

1/ For testing purpose of a strategy i would like to do something like (test what happens if i close my trade after 30 minutes)

minimal_roi = {'30':-1}
stop_loss = -1

![image](https://user-images.githubusercontent.com/2643583/65308194-d1e59f80-db89-11e9-8ced-8aeb62f72bfc.png)

Freqtrade is not happy with that.

2/ this type of problem appear too when using 

minimal_roi = {'0': 0.02, '30': 0}
stoploss = -0.02

![image](https://user-images.githubusercontent.com/2643583/65308693-e5ddd100-db8a-11e9-9550-2be69f357bd9.png)

14% of the trades have 0% roi but it is not possible because when you close the trade 30 minutes with profit > 0 to must be take into consideration.

I think in live he don't have this type of problem ?

Have a nice day.








Discussion:
I think  "sell-reason reporting" is the real problem, since your sell-reason is always ROI after 30 minutes (since you're abusing it with negative values). 

I think you're misinterpreting sell_reason.
This column tells you why the bot sold (because of your ROI configuration) - not necessarily if it's positive or negative. You can also have trailing-stop as reason, which can be positive or negative, but the reason will still be trailing-stoploss - no matter if it's + or - 5%.

1)
your first configuration seems to work, but produces a huge loss after 30 minutes due to "roi". 
Ignore the sell-reason column for now and load the backtest-results file to pandas as shown in the documentation (i assume you're doing that here anyway, according to the screenshots...)

Assume you load the trades, then override sell_reason with `backtest_df.loc[backtest_df['profitperc'] < 0, 'sell_reason'] = "negative_roi"`, and then do a `value_counts()` grouping by that column again (which will produce the same output than backtesting, but with fixed sell-reasons.

This should produce "some" positive results (maybe not - then your buy-signal is not suitable for 30min trades)...

2)
I'm not certain about how to interpret your second results... you're counting over "profitperc" - so it's telling me that 13% of your trades were sold with 0 profit. Why should this not be possible?





Thinking about 2 again, i know the reason for this too:
backtesting always uses the "roi" value as sell-value. The reason for this is simple - assume your ROI setting is 2% - but "high" was 5% above buy price. 
in Dry/live runs, your bot would have sold at 2%, and not 5% - so using high is wrong, same with close (which could be 4% - but the bot sells as soon as 2% roi is hit).

Now in dry/live runs, you would not see 2% but 2.01% or so (depending on the sell ordertype, how fast the market moves etc) ... but simulating this reliably in backtesting is impossible, since we lack the information for it.

hope this helps explaining this.
Hi,

the problematic i have is solve after n bar/n minutes no matter loss/profit. i would like to manage this with 

```
minimal_roi = {'30':-1}
stop_loss = -1
```

After doing that he stop the trade at 30 minutes but take the roi value : -1 as the profit (-99.9% per trade) and not the close-open value to calculate profit. 

![image](https://user-images.githubusercontent.com/2643583/65606138-9f351000-dfaa-11e9-9061-b1e72fa016d2.png)


2/ Here it is the same problem but with more realistic use case  i want to try closing the trade after 30 minutes :
54% make 2% profit before 30 minutes -> ok
31% touch the stop loss not depending time duration -> ok
14% of the trades are sold after 30 minutes if roi >= 0.
         -> ok if trade open since 30 minutes and current  loss is 1% -> at 40 minutes going up and roi 0 reach !
         -> nok if trade is winning at 30 minutes (current profit at bar close is 1.5%) but report as 0% in backtest.

```
 minimal_roi = {'0': 0.02, '30': 0}
 stoploss = -0.02
```

> ![image](https://user-images.githubusercontent.com/2643583/65308693-e5ddd100-db8a-11e9-9550-2be69f357bd9.png)

Many tks.

Have a nice day.


hi @gaugau3000 , 

sorry for the wait, you're right, this happens because we use the ROI value for sells (assuming it's within a candle).

i've tried to fix this in #2342. 
Would appreciate if you could have a look at the solution / give it a try with your strategy.
I've also created a dummy-testcase for this - but testing it against the strategy where it initially appeared can only be a benefit!

The solution is taking the maximum of the `calculated ROI price` and `low`, since we can't know the 100% correct value within the candle during backtesting.

There is no need to test against the candles High, since ROI is compared as `>` with the candle already, so having sells above the candle are not possible.


@xmatthias many tks :-) your fix seem to work but let me the end of the week end to test (i will dry to dive of little into the code this time). I come back to you.

Have a nice WE.
@xmatthias you can merge it is ok for me (don't have enough time do go into code but i make different test and it is ok)

Have a nice evening :-)
Hi there,

I was thinking if it is possible to take the close of the candle instead of low when the roi time correspond to a candle close (only for negative roi and if stop loss not reach).

Example :

timeframe = 5m
30 is a multiple of 5 so take the close instead of low for computation (correspond to the close of the sixth candle).

> minimal_roi = {'30':-1}
> stop_loss = -1

Have a nice day.
is it possible - yes

is it realistic - i'm not certain (letme explain)
backtesting always trades "on the candle" - so you cannot have a trade that opens 10 seconds after the candle opened.

In real trading though, that's exactly what happens, you'll be 1s, 5s, maybe 10s late (depending on latency, amount of pairs, ...).
So instead of using close, should use open instead.

However, assuming the candle opens, and immediately drops to the candle's low - then using "low" in backtesting would be correct again - so the answer is not clear and i don't think it can be generalized well.

Would backtest results improve when using negative ROI and we'd switch to close or open? Yes sure
would it be better aligned? probably not... 

Hi

> is it possible - yes
> 
> is it realistic - i'm not certain (letme explain)
> backtesting always trades "on the candle" - so you cannot have a trade that opens 10 seconds after the candle opened.
> 
> In real trading though, that's exactly what happens, you'll be 1s, 5s, maybe 10s late (depending on latency, amount of pairs, ...).
> So instead of using close, should use open instead.

I agree.

> However, assuming the candle opens, and immediately drops to the candle's low - then using "low" in backtesting would be correct again - so the answer is not clear and i don't think it can be generalized well.

Why make this type of assumption ?

> Would backtest results improve when using negative ROI and we'd switch to close or open? Yes sure
> would it be better aligned? probably not...

It is ok for me to have a backtest conservative (and maybe it is not enough as spread is not taking into consideration). The only problem is i make stress test on strategies like : does this strategy makes money/have good winrate at the end of the first candle, the second... (it is a way to optimize buys)?

> ticker_interval = "5m"
> minimal_roi = {'5':-1}
> stop_loss = -1

and i am not able to to that for now because it takes the low

I know that it is a limit use case but takes the low is conservative but maybe not realistic (eg : end a trade after 1 candle in 4h timeframe takes the candle low).

Have a nice day.



Resolving patch:
diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py
--- a/freqtrade/optimize/backtesting.py
+++ b/freqtrade/optimize/backtesting.py
@@ -261,6 +261,45 @@ def _get_ticker_list(self, processed) -> Dict[str, DataFrame]:
             ticker[pair] = [x for x in ticker_data.itertuples()]
         return ticker
 
+    def _get_close_rate(self, sell_row, trade: Trade, sell, trade_dur) -> float:
+        """
+        Get close rate for backtesting result
+        """
+        # Special handling if high or low hit STOP_LOSS or ROI
+        if sell.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
+            # Set close_rate to stoploss
+            return trade.stop_loss
+        elif sell.sell_type == (SellType.ROI):
+            roi_entry, roi = self.strategy.min_roi_reached_entry(trade_dur)
+            if roi is not None:
+                if roi == -1 and roi_entry % self.timeframe_mins == 0:
+                    # When forceselling with ROI=-1, the roi time will always be equal to trade_dur.
+                    # If that entry is a multiple of the timeframe (so on candle open)
+                    # - we'll use open instead of close
+                    return sell_row.open
+
+                # - (Expected abs profit + open_rate + open_fee) / (fee_close -1)
+                close_rate = - (trade.open_rate * roi + trade.open_rate *
+                                (1 + trade.fee_open)) / (trade.fee_close - 1)
+
+                if (trade_dur > 0 and trade_dur == roi_entry
+                        and roi_entry % self.timeframe_mins == 0
+                        and sell_row.open > close_rate):
+                    # new ROI entry came into effect.
+                    # use Open rate if open_rate > calculated sell rate
+                    return sell_row.open
+
+                # Use the maximum between close_rate and low as we
+                # cannot sell outside of a candle.
+                # Applies when a new ROI setting comes in place and the whole candle is above that.
+                return max(close_rate, sell_row.low)
+
+            else:
+                # This should not be reached...
+                return sell_row.open
+        else:
+            return sell_row.open
+
     def _get_sell_trade_entry(
             self, pair: str, buy_row: DataFrame,
             partial_ticker: List, trade_count_lock: Dict,
@@ -287,26 +326,7 @@ def _get_sell_trade_entry(
                                              sell_row.sell, low=sell_row.low, high=sell_row.high)
             if sell.sell_flag:
                 trade_dur = int((sell_row.date - buy_row.date).total_seconds() // 60)
-                # Special handling if high or low hit STOP_LOSS or ROI
-                if sell.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
-                    # Set close_rate to stoploss
-                    closerate = trade.stop_loss
-                elif sell.sell_type == (SellType.ROI):
-                    roi = self.strategy.min_roi_reached_entry(trade_dur)
-                    if roi is not None:
-                        # - (Expected abs profit + open_rate + open_fee) / (fee_close -1)
-                        closerate = - (trade.open_rate * roi + trade.open_rate *
-                                       (1 + trade.fee_open)) / (trade.fee_close - 1)
-
-                        # Use the maximum between closerate and low as we
-                        # cannot sell outside of a candle.
-                        # Applies when using {"xx": -1} as roi to force sells after xx minutes
-                        closerate = max(closerate, sell_row.low)
-                    else:
-                        # This should not be reached...
-                        closerate = sell_row.open
-                else:
-                    closerate = sell_row.open
+                closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
 
                 return BacktestResult(pair=pair,
                                       profit_percent=trade.calc_profit_percent(rate=closerate),
diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py
--- a/freqtrade/strategy/interface.py
+++ b/freqtrade/strategy/interface.py
@@ -394,7 +394,7 @@ def stop_loss_reached(self, current_rate: float, trade: Trade,
 
         return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)
 
-    def min_roi_reached_entry(self, trade_dur: int) -> Optional[float]:
+    def min_roi_reached_entry(self, trade_dur: int) -> Tuple[Optional[int], Optional[float]]:
         """
         Based on trade duration defines the ROI entry that may have been reached.
         :param trade_dur: trade duration in minutes
@@ -403,9 +403,9 @@ def min_roi_reached_entry(self, trade_dur: int) -> Optional[float]:
         # Get highest entry in ROI dict where key <= trade-duration
         roi_list = list(filter(lambda x: x <= trade_dur, self.minimal_roi.keys()))
         if not roi_list:
-            return None
+            return None, None
         roi_entry = max(roi_list)
-        return self.minimal_roi[roi_entry]
+        return roi_entry, self.minimal_roi[roi_entry]
 
     def min_roi_reached(self, trade: Trade, current_profit: float, current_time: datetime) -> bool:
         """
@@ -415,7 +415,7 @@ def min_roi_reached(self, trade: Trade, current_profit: float, current_time: dat
         """
         # Check if time matches and current rate is above threshold
         trade_dur = int((current_time.timestamp() - trade.open_date.timestamp()) // 60)
-        roi = self.min_roi_reached_entry(trade_dur)
+        _, roi = self.min_roi_reached_entry(trade_dur)
         if roi is None:
             return False
         else:


Test patch:
diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py
--- a/tests/optimize/test_backtest_detail.py
+++ b/tests/optimize/test_backtest_detail.py
@@ -265,6 +265,69 @@
     trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)]
 )
 
+# Test 17: Buy, hold for 120 mins, then forcesell using roi=-1
+# Causes negative profit even though sell-reason is ROI.
+# stop-loss: 10%, ROI: 10% (should not apply), -100% after 100 minutes (limits trade duration)
+# Uses open as sell-rate (special case) - since the roi-time is a multiple of the ticker interval.
+tc17 = BTContainer(data=[
+    # D  O     H     L     C     V    B  S
+    [0, 5000, 5025, 4975, 4987, 6172, 1, 0],
+    [1, 5000, 5025, 4975, 4987, 6172, 0, 0],
+    [2, 4987, 5300, 4950, 5050, 6172, 0, 0],
+    [3, 4980, 5000, 4940, 4962, 6172, 0, 0],  # ForceSell on ROI (roi=-1)
+    [4, 4962, 4987, 4972, 4950, 6172, 0, 0],
+    [5, 4950, 4975, 4925, 4950, 6172, 0, 0]],
+    stop_loss=-0.10, roi={"0": 0.10, "120": -1}, profit_perc=-0.004,
+    trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)]
+)
+
+
+# Test 18: Buy, hold for 120 mins, then drop ROI to 1%, causing a sell in candle 3.
+# stop-loss: 10%, ROI: 10% (should not apply), -100% after 100 minutes (limits trade duration)
+# uses open_rate as sell-price
+tc18 = BTContainer(data=[
+    # D  O     H     L     C     V    B  S
+    [0, 5000, 5025, 4975, 4987, 6172, 1, 0],
+    [1, 5000, 5025, 4975, 4987, 6172, 0, 0],
+    [2, 4987, 5300, 4950, 5200, 6172, 0, 0],
+    [3, 5200, 5220, 4940, 4962, 6172, 0, 0],  # Sell on ROI (sells on open)
+    [4, 4962, 4987, 4972, 4950, 6172, 0, 0],
+    [5, 4950, 4975, 4925, 4950, 6172, 0, 0]],
+    stop_loss=-0.10, roi={"0": 0.10, "120": 0.01}, profit_perc=0.04,
+    trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)]
+)
+
+# Test 19: Buy, hold for 119 mins, then drop ROI to 1%, causing a sell in candle 3.
+# stop-loss: 10%, ROI: 10% (should not apply), -100% after 100 minutes (limits trade duration)
+# uses calculated ROI (1%) as sell rate, otherwise identical to tc18
+tc19 = BTContainer(data=[
+    # D  O     H     L     C     V    B  S
+    [0, 5000, 5025, 4975, 4987, 6172, 1, 0],
+    [1, 5000, 5025, 4975, 4987, 6172, 0, 0],
+    [2, 4987, 5300, 4950, 5200, 6172, 0, 0],
+    [3, 5000, 5300, 4940, 4962, 6172, 0, 0],  # Sell on ROI
+    [4, 4962, 4987, 4972, 4950, 6172, 0, 0],
+    [5, 4550, 4975, 4925, 4950, 6172, 0, 0]],
+    stop_loss=-0.10, roi={"0": 0.10, "120": 0.01}, profit_perc=0.01,
+    trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)]
+)
+
+# Test 20: Buy, hold for 119 mins, then drop ROI to 1%, causing a sell in candle 3.
+# stop-loss: 10%, ROI: 10% (should not apply), -100% after 100 minutes (limits trade duration)
+# uses calculated ROI (1%) as sell rate, otherwise identical to tc18
+tc20 = BTContainer(data=[
+    # D  O     H     L     C     V    B  S
+    [0, 5000, 5025, 4975, 4987, 6172, 1, 0],
+    [1, 5000, 5025, 4975, 4987, 6172, 0, 0],
+    [2, 4987, 5300, 4950, 5200, 6172, 0, 0],
+    [3, 5200, 5300, 4940, 4962, 6172, 0, 0],  # Sell on ROI
+    [4, 4962, 4987, 4972, 4950, 6172, 0, 0],
+    [5, 4550, 4975, 4925, 4950, 6172, 0, 0]],
+    stop_loss=-0.10, roi={"0": 0.10, "119": 0.01}, profit_perc=0.01,
+    trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=3)]
+)
+
+
 TESTS = [
     tc0,
     tc1,
@@ -283,6 +346,10 @@
     tc14,
     tc15,
     tc16,
+    tc17,
+    tc18,
+    tc19,
+    tc20,
 ]