Repository: freqtrade/freqtrade

Issue:
Buy candle ROI trap
(So we do not forget)

I bumped into a backtesting trap related to roi getting filled on buy candle. Consider this situation:
![image](https://user-images.githubusercontent.com/19151258/150093172-23d256ee-bd2b-434d-a61e-cdc5d1b3748b.png)

Current behavior:
1. Fist candle requests an order placement below candle close using `custom_entry_price()`.
2. Second candle does reach said level and order is considered filled.
3. Second candle fell by more than active ROI, which means there is enough room for price to go up and get filled by ROI.

ROI (on second candle) essentially considers price to move like so: open -> low (order fill) -> high (roi) -> close. This is too optimistic and hardly ever happens.

How it should work:
If buy (second, in the example above) candle hits ROI target:
* Only consider a successful exit for longs on red candle, if entry price >= open.
* Only consider a successful exit for shorts on green candle, if entry price <= open.

This would always assume the pessimistic outcome:
  * Red candles move open->high(only chance for ROI of longs on entry candle, or try stoploss first for shorts)->low->close
  * Green candles move open->low(only chance for ROI of shorts on entry candle, or try stoploss first for longs) ->high->close


Discussion:
A temporary workaround can be added after `self.strategy.should_sell()` call in `backtesting.py`:
```py
        if sell.sell_type in (SellType.ROI, SellType.SELL_SIGNAL):
            order = trade.select_order('buy', is_open=False)
            assert order is not None
            if order.order_filled_date == sell_row[DATE_IDX]:
                # Longs can only hit roi on red candle if trade is opened and closed in top wick.
                if sell_row[OPEN_IDX] > sell_row[CLOSE_IDX]:
                    if trade.open_rate < sell_row[OPEN_IDX]:
                        sell.sell_type = SellType.NONE
                        sell.sell_reason = None
```
wouldn't an easier "workaround" to this be to handle this as special case in `_get_close_rate`  (there's 0 length handling anyway) - and return None (basically rejecting the sell right there)?

obviously that'll need some handling "outside" ... but it actually seems quite easy to accomplish
I did not look into `_get_close_rate` actually. If it can reject a trade then its perfect 👍🏻 
> I did not look into `_get_close_rate` actually. If it can reject a trade then its perfect 👍🏻

it's code - it can do whatever we teach it to ... 

Resolving patch:
diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py
--- a/freqtrade/optimize/backtesting.py
+++ b/freqtrade/optimize/backtesting.py
@@ -357,6 +357,18 @@ def _get_close_rate(self, sell_row: Tuple, trade: LocalTrade, sell: SellCheckTup
                     # use Open rate if open_rate > calculated sell rate
                     return sell_row[OPEN_IDX]
 
+                if (
+                    trade_dur == 0
+                    # Red candle (for longs), TODO: green candle (for shorts)
+                    and sell_row[OPEN_IDX] > sell_row[CLOSE_IDX]  # Red candle
+                    and trade.open_rate < sell_row[OPEN_IDX]  # trade-open below open_rate
+                    and close_rate > sell_row[CLOSE_IDX]
+                ):
+                    # ROI on opening candles with custom pricing can only
+                    # trigger if the entry was at Open or lower.
+                    # details: https: // github.com/freqtrade/freqtrade/issues/6261
+                    # If open_rate is < open, only allow sells below the close on red candles.
+                    raise ValueError("Opening candle ROI on red candles.")
                 # 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.
@@ -414,7 +426,10 @@ def _get_sell_trade_entry_for_candle(self, trade: LocalTrade,
             trade.close_date = sell_candle_time
 
             trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
-            closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
+            try:
+                closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
+            except ValueError:
+                return None
             # call the custom exit price,with default value as previous closerate
             current_profit = trade.calc_profit_ratio(closerate)
             order_type = self.strategy.order_types['sell']


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
@@ -562,9 +562,9 @@
 )
 
 # Test 36: Custom-entry-price around candle low
-# Causes immediate ROI exit. This is currently expected behavior (#6261)
-# https://github.com/freqtrade/freqtrade/issues/6261
-# But may change at a later point.
+# Would cause immediate ROI exit, but since the trade was entered
+# below open, we treat this as cheating, and delay the sell by 1 candle.
+# details: https://github.com/freqtrade/freqtrade/issues/6261
 tc36 = BTContainer(data=[
     # D   O     H     L     C    V    B  S   BT
     [0, 5000, 5050, 4950, 5000, 6172, 1, 0],
@@ -574,13 +574,27 @@
     [4, 4750, 4950, 4350, 4750, 6172, 0, 0]],
     stop_loss=-0.10, roi={"0": 0.01}, profit_perc=0.01,
     custom_entry_price=4952,
-    trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=1)]
+    trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=2)]
 )
 
+# Test 37: Custom-entry-price around candle low
+# Would cause immediate ROI exit below close
+# details: https://github.com/freqtrade/freqtrade/issues/6261
+tc37 = BTContainer(data=[
+    # D   O     H     L     C    V    B  S   BT
+    [0, 5000, 5050, 4950, 5000, 6172, 1, 0],
+    [1, 5400, 5500, 4951, 5100, 6172, 0, 0],    # Enter and immediate ROI
+    [2, 4900, 5250, 4500, 5100, 6172, 0, 0],
+    [3, 5100, 5100, 4650, 4750, 6172, 0, 0],
+    [4, 4750, 4950, 4350, 4750, 6172, 0, 0]],
+    stop_loss=-0.10, roi={"0": 0.01}, profit_perc=0.01,
+    custom_entry_price=4952,
+    trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=1)]
+)
 
-# Test 37: Custom exit price below all candles
+# Test 38: Custom exit price below all candles
 # Price adjusted to candle Low.
-tc37 = BTContainer(data=[
+tc38 = BTContainer(data=[
     # D   O     H     L     C    V    B  S   BT
     [0, 5000, 5050, 4950, 5000, 6172, 1, 0],
     [1, 5000, 5500, 4951, 5000, 6172, 0, 0],
@@ -593,9 +607,9 @@
     trades=[BTrade(sell_reason=SellType.SELL_SIGNAL, open_tick=1, close_tick=3)]
 )
 
-# Test 38: Custom exit price above all candles
+# Test 39: Custom exit price above all candles
 # causes sell signal timeout
-tc38 = BTContainer(data=[
+tc39 = BTContainer(data=[
     # D   O     H     L     C    V    B  S   BT
     [0, 5000, 5050, 4950, 5000, 6172, 1, 0],
     [1, 5000, 5500, 4951, 5000, 6172, 0, 0],
@@ -649,6 +663,7 @@
     tc36,
     tc37,
     tc38,
+    tc39,
 ]