Merge pull request #4750 from rokups/rk/custom_sell

Add IStrategy.custom_sell method which allows per-trade sell signal evaluation
This commit is contained in:
Matthias
2021-04-29 06:50:56 +02:00
committed by GitHub
11 changed files with 249 additions and 129 deletions

View File

@@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional
import arrow
from cachetools import TTLCache
from pandas import DataFrame
from freqtrade import __version__, constants
from freqtrade.configuration import validate_config_consistency
@@ -28,7 +29,7 @@ from freqtrade.plugins.protectionmanager import ProtectionManager
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
from freqtrade.rpc import RPCManager, RPCMessageType
from freqtrade.state import State
from freqtrade.strategy.interface import IStrategy, SellType
from freqtrade.strategy.interface import IStrategy, SellCheckTuple, SellType
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
from freqtrade.wallets import Wallets
@@ -783,10 +784,10 @@ class FreqtradeBot(LoggingMixin):
config_ask_strategy = self.config.get('ask_strategy', {})
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair,
self.strategy.timeframe)
if (config_ask_strategy.get('use_sell_signal', True) or
config_ask_strategy.get('ignore_roi_if_buy_signal', False)):
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair,
self.strategy.timeframe)
(buy, sell) = self.strategy.get_signal(trade.pair, self.strategy.timeframe, analyzed_df)
@@ -813,13 +814,13 @@ class FreqtradeBot(LoggingMixin):
# resulting in outdated RPC messages
self._sell_rate_cache[trade.pair] = sell_rate
if self._check_and_execute_sell(trade, sell_rate, buy, sell):
if self._check_and_execute_sell(analyzed_df, trade, sell_rate, buy, sell):
return True
else:
logger.debug('checking sell')
sell_rate = self.get_sell_rate(trade.pair, True)
if self._check_and_execute_sell(trade, sell_rate, buy, sell):
if self._check_and_execute_sell(analyzed_df, trade, sell_rate, buy, sell):
return True
logger.debug('Found no sell signal for %s.', trade)
@@ -850,7 +851,8 @@ class FreqtradeBot(LoggingMixin):
trade.stoploss_order_id = None
logger.error(f'Unable to place a stoploss order on exchange. {e}')
logger.warning('Selling the trade forcefully')
self.execute_sell(trade, trade.stop_loss, sell_reason=SellType.EMERGENCY_SELL)
self.execute_sell(trade, trade.stop_loss, sell_reason=SellCheckTuple(
sell_type=SellType.EMERGENCY_SELL))
except ExchangeError:
trade.stoploss_order_id = None
@@ -949,19 +951,19 @@ class FreqtradeBot(LoggingMixin):
logger.warning(f"Could not create trailing stoploss order "
f"for pair {trade.pair}.")
def _check_and_execute_sell(self, trade: Trade, sell_rate: float,
def _check_and_execute_sell(self, dataframe: DataFrame, trade: Trade, sell_rate: float,
buy: bool, sell: bool) -> bool:
"""
Check and execute sell
"""
should_sell = self.strategy.should_sell(
trade, sell_rate, datetime.now(timezone.utc), buy, sell,
dataframe, trade, sell_rate, datetime.now(timezone.utc), buy, sell,
force_stoploss=self.edge.stoploss(trade.pair) if self.edge else 0
)
if should_sell.sell_flag:
logger.info(f'Executing Sell for {trade.pair}. Reason: {should_sell.sell_type}')
self.execute_sell(trade, sell_rate, should_sell.sell_type)
self.execute_sell(trade, sell_rate, should_sell)
return True
return False
@@ -1150,16 +1152,16 @@ class FreqtradeBot(LoggingMixin):
raise DependencyException(
f"Not enough amount to sell. Trade-amount: {amount}, Wallet: {wallet_amount}")
def execute_sell(self, trade: Trade, limit: float, sell_reason: SellType) -> bool:
def execute_sell(self, trade: Trade, limit: float, sell_reason: SellCheckTuple) -> bool:
"""
Executes a limit sell for the given trade and limit
:param trade: Trade instance
:param limit: limit rate for the sell order
:param sellreason: Reason the sell was triggered
:param sell_reason: Reason the sell was triggered
:return: True if it succeeds (supported) False (not supported)
"""
sell_type = 'sell'
if sell_reason in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
if sell_reason.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
sell_type = 'stoploss'
# if stoploss is on exchange and we are on dry_run mode,
@@ -1176,10 +1178,10 @@ class FreqtradeBot(LoggingMixin):
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
order_type = self.strategy.order_types[sell_type]
if sell_reason == SellType.EMERGENCY_SELL:
if sell_reason.sell_type == SellType.EMERGENCY_SELL:
# Emergency sells (default to market!)
order_type = self.strategy.order_types.get("emergencysell", "market")
if sell_reason == SellType.FORCE_SELL:
if sell_reason.sell_type == SellType.FORCE_SELL:
# Force sells (default to the sell_type defined in the strategy,
# but we allow this value to be changed)
order_type = self.strategy.order_types.get("forcesell", order_type)
@@ -1190,7 +1192,7 @@ class FreqtradeBot(LoggingMixin):
if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit,
time_in_force=time_in_force,
sell_reason=sell_reason.value):
sell_reason=sell_reason.sell_reason):
logger.info(f"User requested abortion of selling {trade.pair}")
return False
@@ -1213,7 +1215,7 @@ class FreqtradeBot(LoggingMixin):
trade.open_order_id = order['id']
trade.sell_order_status = ''
trade.close_rate_requested = limit
trade.sell_reason = sell_reason.value
trade.sell_reason = sell_reason.sell_reason
# In case of market sell orders the order can be closed immediately
if order.get('status', 'unknown') == 'closed':
self.update_trade_state(trade, trade.open_order_id, order)

View File

@@ -247,15 +247,17 @@ class Backtesting:
else:
return sell_row[OPEN_IDX]
def _get_sell_trade_entry(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]:
def _get_sell_trade_entry(self, dataframe: DataFrame, trade: LocalTrade,
sell_row: Tuple) -> Optional[LocalTrade]:
sell = self.strategy.should_sell(trade, sell_row[OPEN_IDX], # type: ignore
sell_row[DATE_IDX], sell_row[BUY_IDX], sell_row[SELL_IDX],
sell = self.strategy.should_sell(dataframe, trade, sell_row[OPEN_IDX], # type: ignore
sell_row[DATE_IDX].to_pydatetime(), sell_row[BUY_IDX],
sell_row[SELL_IDX],
low=sell_row[LOW_IDX], high=sell_row[HIGH_IDX])
if sell.sell_flag:
trade.close_date = sell_row[DATE_IDX]
trade.sell_reason = sell.sell_type.value
trade.close_date = sell_row[DATE_IDX].to_pydatetime()
trade.sell_reason = sell.sell_reason
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)
@@ -265,7 +267,7 @@ class Backtesting:
pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount,
rate=closerate,
time_in_force=time_in_force,
sell_reason=sell.sell_type.value):
sell_reason=sell.sell_reason):
return None
trade.close(closerate, show_msg=False)
@@ -293,7 +295,7 @@ class Backtesting:
trade = LocalTrade(
pair=pair,
open_rate=row[OPEN_IDX],
open_date=row[DATE_IDX],
open_date=row[DATE_IDX].to_pydatetime(),
stake_amount=stake_amount,
amount=round(stake_amount / row[OPEN_IDX], 8),
fee_open=self.fee,
@@ -315,7 +317,7 @@ class Backtesting:
for trade in open_trades[pair]:
sell_row = data[pair][-1]
trade.close_date = sell_row[DATE_IDX]
trade.close_date = sell_row[DATE_IDX].to_pydatetime()
trade.sell_reason = SellType.FORCE_SELL.value
trade.close(sell_row[OPEN_IDX], show_msg=False)
LocalTrade.close_bt_trade(trade)
@@ -396,7 +398,7 @@ class Backtesting:
for trade in open_trades[pair]:
# also check the buying candle for sell conditions.
trade_entry = self._get_sell_trade_entry(trade, row)
trade_entry = self._get_sell_trade_entry(processed[pair], trade, row)
# Sell occured
if trade_entry:
# logger.debug(f"{pair} - Backtesting sell {trade}")

View File

@@ -24,7 +24,7 @@ from freqtrade.persistence.models import PairLock
from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist
from freqtrade.rpc.fiat_convert import CryptoToFiatConverter
from freqtrade.state import State
from freqtrade.strategy.interface import SellType
from freqtrade.strategy.interface import SellCheckTuple, SellType
logger = logging.getLogger(__name__)
@@ -554,7 +554,8 @@ class RPC:
if not fully_canceled:
# Get current rate and execute sell
current_rate = self._freqtrade.get_sell_rate(trade.pair, False)
self._freqtrade.execute_sell(trade, current_rate, SellType.FORCE_SELL)
sell_reason = SellCheckTuple(sell_type=SellType.FORCE_SELL)
self._freqtrade.execute_sell(trade, current_rate, sell_reason)
# ---- EOF def _exec_forcesell ----
if self._freqtrade.state != State.RUNNING:

View File

@@ -7,7 +7,7 @@ import warnings
from abc import ABC, abstractmethod
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Dict, List, NamedTuple, Optional, Tuple
from typing import Dict, List, Optional, Tuple, Union
import arrow
from pandas import DataFrame
@@ -24,6 +24,7 @@ from freqtrade.wallets import Wallets
logger = logging.getLogger(__name__)
CUSTOM_SELL_MAX_LENGTH = 64
class SignalType(Enum):
@@ -45,6 +46,7 @@ class SellType(Enum):
SELL_SIGNAL = "sell_signal"
FORCE_SELL = "force_sell"
EMERGENCY_SELL = "emergency_sell"
CUSTOM_SELL = "custom_sell"
NONE = ""
def __str__(self):
@@ -52,12 +54,20 @@ class SellType(Enum):
return self.value
class SellCheckTuple(NamedTuple):
class SellCheckTuple(object):
"""
NamedTuple for Sell type + reason
"""
sell_flag: bool
sell_type: SellType
sell_reason: str = ''
def __init__(self, sell_type: SellType, sell_reason: str = ''):
self.sell_type = sell_type
self.sell_reason = sell_reason or sell_type.value
@property
def sell_flag(self):
return self.sell_type != SellType.NONE
class IStrategy(ABC, HyperStrategyMixin):
@@ -264,7 +274,7 @@ class IStrategy(ABC, HyperStrategyMixin):
return True
def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, **kwargs) -> float:
current_profit: float, dataframe: DataFrame, **kwargs) -> float:
"""
Custom stoploss logic, returning the new distance relative to current_rate (as ratio).
e.g. returning -0.05 would create a stoploss 5% below current_rate.
@@ -280,11 +290,37 @@ class IStrategy(ABC, HyperStrategyMixin):
:param current_time: datetime object, containing the current datetime
:param current_rate: Rate, calculated based on pricing settings in ask_strategy.
:param current_profit: Current profit (as ratio), calculated based on current_rate.
:param dataframe: Analyzed dataframe for this pair. Can contain future data in backtesting.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New stoploss value, relative to the currentrate
"""
return self.stoploss
def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float,
current_profit: float, dataframe: DataFrame,
**kwargs) -> Optional[Union[str, bool]]:
"""
Custom sell signal logic indicating that specified position should be sold. Returning a
string or True from this method is equal to setting sell signal on a candle at specified
time. This method is not called when sell signal is set.
This method should be overridden to create sell signals that depend on trade parameters. For
example you could implement a stoploss relative to candle when trade was opened, or a custom
1:2 risk-reward ROI.
Custom sell reason max length is 64. Exceeding this limit will raise OperationalException.
:param pair: Pair that's currently analyzed
:param trade: trade object.
:param current_time: datetime object, containing the current datetime
:param current_rate: Rate, calculated based on pricing settings in ask_strategy.
:param current_profit: Current profit (as ratio), calculated based on current_rate.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return: To execute sell, return a string with custom sell reason or True. Otherwise return
None or False.
"""
return None
def informative_pairs(self) -> ListPairsWithTimeframes:
"""
Define additional, informative pair/interval combinations to be cached from the exchange.
@@ -500,8 +536,8 @@ class IStrategy(ABC, HyperStrategyMixin):
else:
return False
def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool,
sell: bool, low: float = None, high: float = None,
def should_sell(self, dataframe: DataFrame, trade: Trade, rate: float, date: datetime,
buy: bool, sell: bool, low: float = None, high: float = None,
force_stoploss: float = 0) -> SellCheckTuple:
"""
This function evaluates if one of the conditions required to trigger a sell
@@ -517,8 +553,9 @@ class IStrategy(ABC, HyperStrategyMixin):
trade.adjust_min_max_rates(high or current_rate)
stoplossflag = self.stop_loss_reached(current_rate=current_rate, trade=trade,
current_time=date, current_profit=current_profit,
stoplossflag = self.stop_loss_reached(dataframe=dataframe, current_rate=current_rate,
trade=trade, current_time=date,
current_profit=current_profit,
force_stoploss=force_stoploss, high=high)
# Set current rate to high for backtesting sell
@@ -531,12 +568,29 @@ class IStrategy(ABC, HyperStrategyMixin):
and self.min_roi_reached(trade=trade, current_profit=current_profit,
current_time=date))
sell_signal = SellType.NONE
custom_reason = ''
if (ask_strategy.get('sell_profit_only', False)
and current_profit <= ask_strategy.get('sell_profit_offset', 0)):
# sell_profit_only and profit doesn't reach the offset - ignore sell signal
sell_signal = False
else:
sell_signal = sell and not buy and ask_strategy.get('use_sell_signal', True)
pass
elif ask_strategy.get('use_sell_signal', True) and not buy:
if sell:
sell_signal = SellType.SELL_SIGNAL
else:
custom_reason = strategy_safe_wrapper(self.custom_sell, default_retval=False)(
pair=trade.pair, trade=trade, current_time=date, current_rate=current_rate,
current_profit=current_profit, dataframe=dataframe)
if custom_reason:
sell_signal = SellType.CUSTOM_SELL
if isinstance(custom_reason, str):
if len(custom_reason) > CUSTOM_SELL_MAX_LENGTH:
logger.warning(f'Custom sell reason returned from custom_sell is too '
f'long and was trimmed to {CUSTOM_SELL_MAX_LENGTH} '
f'characters.')
custom_reason = custom_reason[:CUSTOM_SELL_MAX_LENGTH]
else:
custom_reason = None
# TODO: return here if sell-signal should be favored over ROI
# Start evaluations
@@ -545,26 +599,25 @@ class IStrategy(ABC, HyperStrategyMixin):
# Sell-signal
# Stoploss
if roi_reached and stoplossflag.sell_type != SellType.STOP_LOSS:
logger.debug(f"{trade.pair} - Required profit reached. sell_flag=True, "
f"sell_type=SellType.ROI")
return SellCheckTuple(sell_flag=True, sell_type=SellType.ROI)
logger.debug(f"{trade.pair} - Required profit reached. sell_type=SellType.ROI")
return SellCheckTuple(sell_type=SellType.ROI)
if sell_signal:
logger.debug(f"{trade.pair} - Sell signal received. sell_flag=True, "
f"sell_type=SellType.SELL_SIGNAL")
return SellCheckTuple(sell_flag=True, sell_type=SellType.SELL_SIGNAL)
if sell_signal != SellType.NONE:
logger.debug(f"{trade.pair} - Sell signal received. "
f"sell_type=SellType.{sell_signal.name}" +
(f", custom_reason={custom_reason}" if custom_reason else ""))
return SellCheckTuple(sell_type=sell_signal, sell_reason=custom_reason)
if stoplossflag.sell_flag:
logger.debug(f"{trade.pair} - Stoploss hit. sell_flag=True, "
f"sell_type={stoplossflag.sell_type}")
logger.debug(f"{trade.pair} - Stoploss hit. sell_type={stoplossflag.sell_type}")
return stoplossflag
# This one is noisy, commented out...
# logger.debug(f"{trade.pair} - No sell signal. sell_flag=False")
return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)
# logger.debug(f"{trade.pair} - No sell signal.")
return SellCheckTuple(sell_type=SellType.NONE)
def stop_loss_reached(self, current_rate: float, trade: Trade,
def stop_loss_reached(self, dataframe: DataFrame, current_rate: float, trade: Trade,
current_time: datetime, current_profit: float,
force_stoploss: float, high: float = None) -> SellCheckTuple:
"""
@@ -582,7 +635,8 @@ class IStrategy(ABC, HyperStrategyMixin):
)(pair=trade.pair, trade=trade,
current_time=current_time,
current_rate=current_rate,
current_profit=current_profit)
current_profit=current_profit,
dataframe=dataframe)
# Sanity check - error cases will return None
if stop_loss_value:
# logger.info(f"{trade.pair} {stop_loss_value=} {current_profit=}")
@@ -626,9 +680,9 @@ class IStrategy(ABC, HyperStrategyMixin):
logger.debug(f"{trade.pair} - Trailing stop saved "
f"{trade.stop_loss - trade.initial_stop_loss:.6f}")
return SellCheckTuple(sell_flag=True, sell_type=sell_type)
return SellCheckTuple(sell_type=sell_type)
return SellCheckTuple(sell_flag=False, sell_type=SellType.NONE)
return SellCheckTuple(sell_type=SellType.NONE)
def min_roi_reached_entry(self, trade_dur: int) -> Tuple[Optional[int], Optional[float]]:
"""

View File

@@ -14,8 +14,9 @@ def bot_loop_start(self, **kwargs) -> None:
use_custom_stoploss = True
def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float,
current_profit: float, **kwargs) -> float:
def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime',
current_rate: float, current_profit: float, dataframe: DataFrame,
**kwargs) -> float:
"""
Custom stoploss logic, returning the new distance relative to current_rate (as ratio).
e.g. returning -0.05 would create a stoploss 5% below current_rate.
@@ -31,6 +32,7 @@ def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime', c
:param current_time: datetime object, containing the current datetime
:param current_rate: Rate, calculated based on pricing settings in ask_strategy.
:param current_profit: Current profit (as ratio), calculated based on current_rate.
:param dataframe: Analyzed dataframe for this pair. Can contain future data in backtesting.
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return float: New stoploss value, relative to the currentrate
"""