diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 0dc0ba4e9..38feada4b 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -12,6 +12,7 @@ Currently available callbacks: * [`custom_stake_amount()`](#stake-size-management) * [`custom_exit()`](#custom-exit-signal) * [`custom_stoploss()`](#custom-stoploss) +* [`custom_roi()`](#custom-roi) * [`custom_entry_price()` and `custom_exit_price()`](#custom-order-price-rules) * [`check_entry_timeout()` and `check_exit_timeout()`](#custom-order-timeout-rules) * [`confirm_trade_entry()`](#trade-entry-buy-order-confirmation) @@ -499,6 +500,135 @@ The helper function `stoploss_from_absolute()` can be used to convert from an ab --- +## Custom ROI + +Called for open trade every iteration (roughly every 5 seconds) until a trade is closed. + +The usage of the custom ROI method must be enabled by setting `use_custom_roi=True` on the strategy object. + +This method allows you to define a custom minimum ROI threshold for exiting a trade, expressed as a ratio (e.g., `0.05` for 5% profit). If both `minimal_roi` and `custom_roi` are defined, the lower of the two thresholds will trigger an exit. For example, if `minimal_roi` is set to `{"0": 0.10}` (10% at 0 minutes) and `custom_roi` returns `0.05`, the trade will exit when the profit reaches 5%. Also, if `custom_roi` returns `0.10` and `minimal_roi` is set to `{"0": 0.05}` (5% at 0 minutes), the trade will be closed when the profit reaches 5%. + +The method must return a float representing the new ROI threshold as a ratio, or `None` to fall back to the `minimal_roi` logic. Returning `NaN` or `inf` values is considered invalid and will be treated as `None`, causing the bot to use the `minimal_roi` configuration. + +### Custom ROI examples + +The following examples illustrate how to use the `custom_roi` function to implement different ROI logics. + +#### Custom ROI per side + +Use different ROI thresholds depending on the `side`. In this example, 5% for long entries and 2% for short entries. + +```python +# Default imports + +class AwesomeStrategy(IStrategy): + + use_custom_roi = True + + # ... populate_* methods + + def custom_roi(self, pair: str, trade: Trade, current_time: datetime, trade_duration: int, + entry_tag: str | None, side: str, **kwargs) -> float | None: + """ + Custom ROI logic, returns a new minimum ROI threshold (as a ratio, e.g., 0.05 for +5%). + Only called when use_custom_roi is set to True. + + If used at the same time as minimal_roi, an exit will be triggered when the lower + threshold is reached. Example: If minimal_roi = {"0": 0.01} and custom_roi returns 0.05, + an exit will be triggered if profit reaches 5%. + + :param pair: Pair that's currently analyzed. + :param trade: trade object. + :param current_time: datetime object, containing the current datetime. + :param trade_duration: Current trade duration in minutes. + :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. + :param side: 'long' or 'short' - indicating the direction of the current trade. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New ROI value as a ratio, or None to fall back to minimal_roi logic. + """ + return 0.05 if side == "long" else 0.02 +``` + +#### Custom ROI per pair + +Use different ROI thresholds depending on the `pair`. + +```python +# Default imports + +class AwesomeStrategy(IStrategy): + + use_custom_roi = True + + # ... populate_* methods + + def custom_roi(self, pair: str, trade: Trade, current_time: datetime, trade_duration: int, + entry_tag: str | None, side: str, **kwargs) -> float | None: + + stake = trade.stake_currency + roi_map = { + f"BTC/{stake}": 0.02, # 2% for BTC + f"ETH/{stake}": 0.03, # 3% for ETH + f"XRP/{stake}": 0.04, # 4% for XRP + } + + return roi_map.get(pair, 0.01) # 1% for any other pair +``` + +#### Custom ROI per entry tag + +Use different ROI thresholds depending on the `entry_tag` provided with the buy signal. + +```python +# Default imports + +class AwesomeStrategy(IStrategy): + + use_custom_roi = True + + # ... populate_* methods + + def custom_roi(self, pair: str, trade: Trade, current_time: datetime, trade_duration: int, + entry_tag: str | None, side: str, **kwargs) -> float | None: + + roi_by_tag = { + "breakout": 0.08, # 8% if tag is "breakout" + "rsi_overbought": 0.05, # 5% if tag is "rsi_overbought" + "mean_reversion": 0.03, # 3% if tag is "mean_reversion" + } + + return roi_by_tag.get(entry_tag, 0.01) # 1% if tag is unknown +``` + +#### Custom ROI based on ATR + +ROI value may be derived from indicators stored in dataframe. This example uses the ATR ratio as ROI. + +``` python +# Default imports +# <...> +import talib.abstract as ta + +class AwesomeStrategy(IStrategy): + + use_custom_roi = True + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + # <...> + dataframe["atr"] = ta.ATR(dataframe, timeperiod=10) + + def custom_roi(self, pair: str, trade: Trade, current_time: datetime, trade_duration: int, + entry_tag: str | None, side: str, **kwargs) -> float | None: + + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + last_candle = dataframe.iloc[-1].squeeze() + atr_ratio = last_candle["atr"] / last_candle["close"] + + return atr_ratio # Returns the ATR value as ratio +``` + +--- + ## Custom order price rules By default, freqtrade use the orderbook to automatically set an order price([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy. diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 52f1ac8ca..2d58c461a 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -501,7 +501,12 @@ class Backtesting: return data def _get_close_rate( - self, row: tuple, trade: LocalTrade, exit_: ExitCheckTuple, trade_dur: int + self, + row: tuple, + trade: LocalTrade, + current_time: datetime, + exit_: ExitCheckTuple, + trade_dur: int, ) -> float: """ Get close rate for backtesting result @@ -514,7 +519,7 @@ class Backtesting: ): return self._get_close_rate_for_stoploss(row, trade, exit_, trade_dur) elif exit_.exit_type == (ExitType.ROI): - return self._get_close_rate_for_roi(row, trade, exit_, trade_dur) + return self._get_close_rate_for_roi(row, trade, current_time, exit_, trade_dur) else: return row[OPEN_IDX] @@ -573,12 +578,21 @@ class Backtesting: return stoploss_value def _get_close_rate_for_roi( - self, row: tuple, trade: LocalTrade, exit_: ExitCheckTuple, trade_dur: int + self, + row: tuple, + trade: LocalTrade, + current_time: datetime, + exit_: ExitCheckTuple, + trade_dur: int, ) -> float: is_short = trade.is_short or False leverage = trade.leverage or 1.0 side_1 = -1 if is_short else 1 - roi_entry, roi = self.strategy.min_roi_reached_entry(trade_dur) + roi_entry, roi = self.strategy.min_roi_reached_entry( + trade, # type: ignore[arg-type] + trade_dur, + current_time, + ) if roi is not None and roi_entry is not None: if roi == -1 and roi_entry % self.timeframe_min == 0: # When force_exiting with ROI=-1, the roi time will always be equal to trade_dur. @@ -785,7 +799,7 @@ class Backtesting: amount_ = amount if amount is not None else trade.amount trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60) try: - close_rate = self._get_close_rate(row, trade, exit_, trade_dur) + close_rate = self._get_close_rate(row, trade, current_time, exit_, trade_dur) except ValueError: return None # call the custom exit price,with default value as previous close_rate diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 9517a85da..2186ea9b3 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -68,6 +68,7 @@ class IStrategy(ABC, HyperStrategyMixin): _ft_params_from_file: dict # associated minimal roi minimal_roi: dict = {} + use_custom_roi: bool = False # associated stoploss stoploss: float @@ -467,6 +468,35 @@ class IStrategy(ABC, HyperStrategyMixin): """ return self.stoploss + def custom_roi( + self, + pair: str, + trade: Trade, + current_time: datetime, + trade_duration: int, + entry_tag: str | None, + side: str, + **kwargs, + ) -> float | None: + """ + Custom ROI logic, returns a new minimum ROI threshold (as a ratio, e.g., 0.05 for +5%). + Only called when use_custom_roi is set to True. + + If used at the same time as minimal_roi, an exit will be triggered when the lower + threshold is reached. Example: If minimal_roi = {"0": 0.01} and custom_roi returns 0.05, + an exit will be triggered if profit reaches 5%. + + :param pair: Pair that's currently analyzed. + :param trade: trade object. + :param current_time: datetime object, containing the current datetime. + :param trade_duration: Current trade duration in minutes. + :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. + :param side: 'long' or 'short' - indicating the direction of the current trade. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New ROI value as a ratio, or None to fall back to minimal_roi logic. + """ + return None + def custom_entry_price( self, pair: str, @@ -1616,18 +1646,49 @@ class IStrategy(ABC, HyperStrategyMixin): return ExitCheckTuple(exit_type=ExitType.NONE) - def min_roi_reached_entry(self, trade_dur: int) -> tuple[int | None, float | None]: + def min_roi_reached_entry( + self, + trade: Trade, + trade_dur: int, + current_time: datetime, + ) -> tuple[int | None, float | None]: """ Based on trade duration defines the ROI entry that may have been reached. :param trade_dur: trade duration in minutes :return: minimal ROI entry value or None if none proper ROI entry was found. """ + + # Get custom ROI if use_custom_roi is set to True + custom_roi = None + if self.use_custom_roi: + custom_roi = strategy_safe_wrapper( + self.custom_roi, default_retval=None, supress_error=True + )( + pair=trade.pair, + trade=trade, + current_time=current_time, + trade_duration=trade_dur, + entry_tag=trade.enter_tag, + side=trade.trade_direction, + ) + if custom_roi is None or isnan(custom_roi) or isinf(custom_roi): + custom_roi = None + logger.debug(f"Custom ROI function did not return a valid ROI for {trade.pair}") + # Get highest entry in ROI dict where key <= trade-duration roi_list = [x for x in self.minimal_roi.keys() if x <= trade_dur] - if not roi_list: - return None, None - roi_entry = max(roi_list) - return roi_entry, self.minimal_roi[roi_entry] + if roi_list: + roi_entry = max(roi_list) + min_roi = self.minimal_roi[roi_entry] + else: + roi_entry = None + min_roi = None + + # The lowest available value is used to trigger an exit. + if custom_roi is not None and (min_roi is None or custom_roi < min_roi): + return trade_dur, custom_roi + else: + return roi_entry, min_roi def min_roi_reached(self, trade: Trade, current_profit: float, current_time: datetime) -> bool: """ @@ -1638,7 +1699,7 @@ class IStrategy(ABC, HyperStrategyMixin): """ # Check if time matches and current rate is above threshold trade_dur = int((current_time.timestamp() - trade.open_date_utc.timestamp()) // 60) - _, roi = self.min_roi_reached_entry(trade_dur) + _, roi = self.min_roi_reached_entry(trade, trade_dur, current_time) if roi is None: return False else: diff --git a/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 b/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 index 306329742..6cad5ea07 100644 --- a/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 +++ b/freqtrade/templates/strategy_subtemplates/strategy_methods_advanced.j2 @@ -135,6 +135,37 @@ def custom_stake_amount( """ return proposed_stake +use_custom_roi = True + +def custom_roi( + self, + pair: str, + trade: Trade, + current_time: datetime, + trade_duration: int, + entry_tag: str | None, + side: str, + **kwargs, +) -> float | None: + """ + Custom ROI logic, returns a new minimum ROI threshold (as a ratio, e.g., 0.05 for +5%). + Only called when use_custom_roi is set to True. + + If used at the same time as minimal_roi, an exit will be triggered when the lower + threshold is reached. Example: If minimal_roi = {"0": 0.01} and custom_roi returns 0.05, + an exit will be triggered if profit reaches 5%. + + :param pair: Pair that's currently analyzed. + :param trade: trade object. + :param current_time: datetime object, containing the current datetime. + :param trade_duration: Current trade duration in minutes. + :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. + :param side: 'long' or 'short' - indicating the direction of the current trade. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New ROI value as a ratio, or None to fall back to minimal_roi logic. + """ + return None + use_custom_stoploss = True def custom_stoploss( diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index ee4388b4d..f9ae4abdc 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -411,6 +411,57 @@ def test_min_roi_reached3(default_conf, fee) -> None: assert strategy.min_roi_reached(trade, 0.31, dt_now() - timedelta(minutes=2)) +def test_min_roi_reached_custom_roi(default_conf, fee) -> None: + strategy = StrategyResolver.load_strategy(default_conf) + # Move traditional ROI out of the way + strategy.minimal_roi = {0: 2000} + strategy.use_custom_roi = True + + def custom_roi(*args, trade: Trade, current_time: datetime, **kwargs): + trade_dur = int((current_time.timestamp() - trade.open_date_utc.timestamp()) // 60) + # Profit is reduced after 30 minutes. + if trade.pair == "XRP/BTC": + return 0.2 + if trade_dur > 30: + return 0.05 + return 0.1 + + strategy.custom_roi = MagicMock(side_effect=custom_roi) + + trade = Trade( + pair="ETH/BTC", + stake_amount=0.001, + amount=5, + open_date=dt_now() - timedelta(hours=1), + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange="binance", + open_rate=1, + ) + + assert not strategy.min_roi_reached(trade, 0.02, dt_now() - timedelta(minutes=56)) + assert strategy.custom_roi.call_count == 1 + assert strategy.min_roi_reached(trade, 0.12, dt_now() - timedelta(minutes=56)) + + # after 30 minutes, the profit is reduced to 5% + assert strategy.min_roi_reached(trade, 0.12, dt_now() - timedelta(minutes=29)) + assert strategy.min_roi_reached(trade, 0.06, dt_now() - timedelta(minutes=29)) + assert strategy.min_roi_reached(trade, 0.051, dt_now() - timedelta(minutes=29)) + # Comparison to exactly 5% should not trigger + assert not strategy.min_roi_reached(trade, 0.05, dt_now() - timedelta(minutes=29)) + + # XRP/BTC has a custom roi of 20%. + + trade.pair = "XRP/BTC" + assert not strategy.min_roi_reached(trade, 0.12, dt_now() - timedelta(minutes=56)) + assert not strategy.min_roi_reached(trade, 0.12, dt_now() - timedelta(minutes=1)) + # XRP/BTC is not time related + assert strategy.min_roi_reached(trade, 0.201, dt_now() - timedelta(minutes=1)) + assert strategy.min_roi_reached(trade, 0.201, dt_now() - timedelta(minutes=56)) + + assert strategy.custom_roi.call_count == 10 + + @pytest.mark.parametrize( "profit,adjusted,expected,liq,trailing,custom,profit2,adjusted2,expected2,custom_stop", [