Merge pull request #11656 from mrpabloyeah/add-custom-roi-strategy-callback

Add custom_roi() strategy callback
This commit is contained in:
Matthias
2025-05-25 09:13:32 +02:00
committed by GitHub
5 changed files with 298 additions and 11 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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:

View File

@@ -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(

View File

@@ -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",
[