mirror of
https://github.com/freqtrade/freqtrade.git
synced 2025-11-29 00:23:07 +00:00
Merge pull request #11656 from mrpabloyeah/add-custom-roi-strategy-callback
Add custom_roi() strategy callback
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user