diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index 7dfa6b0f2..8cdc5f614 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -220,15 +220,40 @@ class StrategyResolver(IResolver): ) if strategy: - strategy._populate_fun_len = len(getfullargspec(strategy.populate_indicators).args) - strategy._buy_fun_len = len(getfullargspec(strategy.populate_buy_trend).args) - strategy._sell_fun_len = len(getfullargspec(strategy.populate_sell_trend).args) - if any(x == 2 for x in [ - strategy._populate_fun_len, - strategy._buy_fun_len, - strategy._sell_fun_len - ]): - strategy.INTERFACE_VERSION = 1 + if strategy.config.get('trading_mode', TradingMode.SPOT) != TradingMode.SPOT: + # Require new method + if not check_override(strategy, IStrategy, 'populate_entry_trend'): + raise OperationalException("`populate_entry_trend` must be implemented.") + if not check_override(strategy, IStrategy, 'populate_exit_trend'): + raise OperationalException("`populate_exit_trend` must be implemented.") + if check_override(strategy, IStrategy, 'custom_sell'): + raise OperationalException( + "Please migrate your implementation of `custom_sell` to `custom_exit`.") + else: + # TODO: Implementing one of the following methods should show a deprecation warning + # buy_trend and sell_trend, custom_sell + if ( + not check_override(strategy, IStrategy, 'populate_buy_trend') + and not check_override(strategy, IStrategy, 'populate_entry_trend') + ): + raise OperationalException( + "`populate_entry_trend` or `populate_buy_trend` must be implemented.") + if ( + not check_override(strategy, IStrategy, 'populate_sell_trend') + and not check_override(strategy, IStrategy, 'populate_exit_trend') + ): + raise OperationalException( + "`populate_exit_trend` or `populate_sell_trend` must be implemented.") + + strategy._populate_fun_len = len(getfullargspec(strategy.populate_indicators).args) + strategy._buy_fun_len = len(getfullargspec(strategy.populate_buy_trend).args) + strategy._sell_fun_len = len(getfullargspec(strategy.populate_sell_trend).args) + if any(x == 2 for x in [ + strategy._populate_fun_len, + strategy._buy_fun_len, + strategy._sell_fun_len + ]): + strategy.INTERFACE_VERSION = 1 return strategy @@ -236,3 +261,11 @@ class StrategyResolver(IResolver): f"Impossible to load Strategy '{strategy_name}'. This class does not exist " "or contains Python code errors." ) + + +def check_override(object, parentclass, attribute): + """ + Checks if a object overrides the parent class attribute. + :returns: True if the object is overridden. + """ + return getattr(type(object), attribute) != getattr(parentclass, attribute) diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 17233a027..975e9d41f 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -29,7 +29,7 @@ from freqtrade.wallets import Wallets logger = logging.getLogger(__name__) -CUSTOM_SELL_MAX_LENGTH = 64 +CUSTOM_EXIT_MAX_LENGTH = 64 class SellCheckTuple: @@ -177,19 +177,27 @@ class IStrategy(ABC, HyperStrategyMixin): """ return dataframe - @abstractmethod def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ - Based on TA indicators, populates the buy signal for the given dataframe + DEPRECATED - please migrate to populate_entry_trend :param dataframe: DataFrame :param metadata: Additional information, like the currently traded pair :return: DataFrame with buy column """ return dataframe - @abstractmethod + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Based on TA indicators, populates the entry signal for the given dataframe + :param dataframe: DataFrame + :param metadata: Additional information, like the currently traded pair + :return: DataFrame with entry columns populated + """ + return self.populate_buy_trend(dataframe, metadata) + def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ + DEPRECATED - please migrate to populate_exit_trend Based on TA indicators, populates the sell signal for the given dataframe :param dataframe: DataFrame :param metadata: Additional information, like the currently traded pair @@ -197,6 +205,15 @@ class IStrategy(ABC, HyperStrategyMixin): """ return dataframe + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Based on TA indicators, populates the exit signal for the given dataframe + :param dataframe: DataFrame + :param metadata: Additional information, like the currently traded pair + :return: DataFrame with exit columns populated + """ + return self.populate_sell_trend(dataframe, metadata) + def bot_loop_start(self, **kwargs) -> None: """ Called at the start of the bot iteration (one loop). @@ -363,6 +380,7 @@ class IStrategy(ABC, HyperStrategyMixin): def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, current_profit: float, **kwargs) -> Optional[Union[str, bool]]: """ + DEPRECATED - please use custom_exit instead. Custom exit signal logic indicating that specified position should be sold. Returning a string or True from this method is equal to setting exit signal on a candle at specified time. This method is not called when exit signal is set. @@ -384,6 +402,30 @@ class IStrategy(ABC, HyperStrategyMixin): """ return None + def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, + current_profit: float, **kwargs) -> Optional[Union[str, bool]]: + """ + Custom exit signal logic indicating that specified position should be sold. Returning a + string or True from this method is equal to setting exit signal on a candle at specified + time. This method is not called when exit signal is set. + + This method should be overridden to create exit signals that depend on trade parameters. For + example you could implement an exit relative to the candle when the trade was opened, + or a custom 1:2 risk-reward ROI. + + Custom exit reason max length is 64. Exceeding characters will be removed. + + :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 exit, return a string with custom sell reason or True. Otherwise return + None or False. + """ + return self.custom_sell(pair, trade, current_time, current_rate, current_profit, **kwargs) + def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, proposed_stake: float, min_stake: float, max_stake: float, entry_tag: Optional[str], side: str, **kwargs) -> float: @@ -849,17 +891,17 @@ class IStrategy(ABC, HyperStrategyMixin): sell_signal = SellType.SELL_SIGNAL else: trade_type = "exit_short" if trade.is_short else "sell" - custom_reason = strategy_safe_wrapper(self.custom_sell, default_retval=False)( + custom_reason = strategy_safe_wrapper(self.custom_exit, default_retval=False)( pair=trade.pair, trade=trade, current_time=current_time, current_rate=current_rate, current_profit=current_profit) if custom_reason: sell_signal = SellType.CUSTOM_SELL if isinstance(custom_reason, str): - if len(custom_reason) > CUSTOM_SELL_MAX_LENGTH: + if len(custom_reason) > CUSTOM_EXIT_MAX_LENGTH: logger.warning(f'Custom {trade_type} reason returned from ' - f'custom_{trade_type} is too long and was trimmed' - f'to {CUSTOM_SELL_MAX_LENGTH} characters.') - custom_reason = custom_reason[:CUSTOM_SELL_MAX_LENGTH] + f'custom_exit is too long and was trimmed' + f'to {CUSTOM_EXIT_MAX_LENGTH} characters.') + custom_reason = custom_reason[:CUSTOM_EXIT_MAX_LENGTH] else: custom_reason = None if sell_signal in (SellType.CUSTOM_SELL, SellType.SELL_SIGNAL): @@ -1072,7 +1114,7 @@ class IStrategy(ABC, HyperStrategyMixin): "the current function headers!", DeprecationWarning) df = self.populate_buy_trend(dataframe) # type: ignore else: - df = self.populate_buy_trend(dataframe, metadata) + df = self.populate_entry_trend(dataframe, metadata) if 'enter_long' not in df.columns: df = df.rename({'buy': 'enter_long', 'buy_tag': 'enter_tag'}, axis='columns') @@ -1094,7 +1136,7 @@ class IStrategy(ABC, HyperStrategyMixin): "the current function headers!", DeprecationWarning) df = self.populate_sell_trend(dataframe) # type: ignore else: - df = self.populate_sell_trend(dataframe, metadata) + df = self.populate_exit_trend(dataframe, metadata) if 'exit_long' not in df.columns: df = df.rename({'sell': 'exit_long'}, axis='columns') return df diff --git a/freqtrade/templates/base_strategy.py.j2 b/freqtrade/templates/base_strategy.py.j2 index 883444a50..ef8f46f5c 100644 --- a/freqtrade/templates/base_strategy.py.j2 +++ b/freqtrade/templates/base_strategy.py.j2 @@ -29,7 +29,7 @@ class {{ strategy }}(IStrategy): You must keep: - the lib in the section "Do not remove these libs" - - the methods: populate_indicators, populate_buy_trend, populate_sell_trend + - the methods: populate_indicators, populate_entry_trend, populate_exit_trend You should keep: - timeframe, minimal_roi, stoploss, trailing_* """ @@ -119,12 +119,12 @@ class {{ strategy }}(IStrategy): return dataframe - def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ - Based on TA indicators, populates the buy signal for the given dataframe - :param dataframe: DataFrame populated with indicators + Based on TA indicators, populates the entry signal for the given dataframe + :param dataframe: DataFrame :param metadata: Additional information, like the currently traded pair - :return: DataFrame with buy column + :return: DataFrame with entry columns populated """ dataframe.loc[ ( @@ -144,12 +144,12 @@ class {{ strategy }}(IStrategy): return dataframe - def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ - Based on TA indicators, populates the sell signal for the given dataframe - :param dataframe: DataFrame populated with indicators + Based on TA indicators, populates the exit signal for the given dataframe + :param dataframe: DataFrame :param metadata: Additional information, like the currently traded pair - :return: DataFrame with buy column + :return: DataFrame with exit columns populated """ dataframe.loc[ ( diff --git a/freqtrade/templates/sample_short_strategy.py b/freqtrade/templates/sample_short_strategy.py index 2b099ee6a..1dfd1df0d 100644 --- a/freqtrade/templates/sample_short_strategy.py +++ b/freqtrade/templates/sample_short_strategy.py @@ -30,7 +30,7 @@ class SampleShortStrategy(IStrategy): You must keep: - the lib in the section "Do not remove these libs" - - the methods: populate_indicators, populate_buy_trend, populate_sell_trend + - the methods: populate_indicators, populate_entry_trend, populate_exit_trend You should keep: - timeframe, minimal_roi, stoploss, trailing_* """ @@ -341,7 +341,7 @@ class SampleShortStrategy(IStrategy): return dataframe - def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Based on TA indicators, populates the buy signal for the given dataframe :param dataframe: DataFrame populated with indicators @@ -361,7 +361,7 @@ class SampleShortStrategy(IStrategy): return dataframe - def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ Based on TA indicators, populates the sell signal for the given dataframe :param dataframe: DataFrame populated with indicators diff --git a/freqtrade/templates/sample_strategy.py b/freqtrade/templates/sample_strategy.py index 69da8b414..fe1bd22fb 100644 --- a/freqtrade/templates/sample_strategy.py +++ b/freqtrade/templates/sample_strategy.py @@ -29,7 +29,7 @@ class SampleStrategy(IStrategy): You must keep: - the lib in the section "Do not remove these libs" - - the methods: populate_indicators, populate_buy_trend, populate_sell_trend + - the methods: populate_indicators, populate_entry_trend, populate_exit_trend You should keep: - timeframe, minimal_roi, stoploss, trailing_* """ @@ -342,12 +342,12 @@ class SampleStrategy(IStrategy): return dataframe - def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ - Based on TA indicators, populates the buy signal for the given dataframe - :param dataframe: DataFrame populated with indicators + Based on TA indicators, populates the entry signal for the given dataframe + :param dataframe: DataFrame :param metadata: Additional information, like the currently traded pair - :return: DataFrame with buy column + :return: DataFrame with entry columns populated """ dataframe.loc[ ( @@ -371,12 +371,12 @@ class SampleStrategy(IStrategy): return dataframe - def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ - Based on TA indicators, populates the sell signal for the given dataframe - :param dataframe: DataFrame populated with indicators + Based on TA indicators, populates the exit signal for the given dataframe + :param dataframe: DataFrame :param metadata: Additional information, like the currently traded pair - :return: DataFrame with sell column + :return: DataFrame with exit columns populated """ dataframe.loc[ ( diff --git a/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 b/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 index d0b56fe8e..d98adfa07 100644 --- a/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 +++ b/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 @@ -92,7 +92,7 @@ def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime', """ return self.stoploss -def custom_sell(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float, +def custom_exit(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float, current_profit: float, **kwargs) -> 'Optional[Union[str, bool]]': """ Custom sell signal logic indicating that specified position should be sold. Returning a diff --git a/tests/strategy/strats/broken_strats/broken_futures_strategies.py b/tests/strategy/strats/broken_strats/broken_futures_strategies.py new file mode 100644 index 000000000..4a84b7491 --- /dev/null +++ b/tests/strategy/strats/broken_strats/broken_futures_strategies.py @@ -0,0 +1,31 @@ +""" +The strategies here are minimal strategies designed to fail loading in certain conditions. +They are not operational, and don't aim to be. +""" + +from datetime import datetime + +from pandas import DataFrame + +from freqtrade.strategy.interface import IStrategy + + +class TestStrategyNoImplements(IStrategy): + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + return super().populate_indicators(dataframe, metadata) + + +class TestStrategyNoImplementSell(TestStrategyNoImplements): + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + return super().populate_entry_trend(dataframe, metadata) + + +class TestStrategyImplementCustomSell(TestStrategyNoImplementSell): + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + return super().populate_exit_trend(dataframe, metadata) + + def custom_sell(self, pair: str, trade, current_time: datetime, + current_rate: float, current_profit: float, + **kwargs): + return False diff --git a/tests/strategy/strats/strategy_test_v3.py b/tests/strategy/strats/strategy_test_v3.py index 7b2c7a99f..168545bbb 100644 --- a/tests/strategy/strats/strategy_test_v3.py +++ b/tests/strategy/strats/strategy_test_v3.py @@ -125,7 +125,7 @@ class StrategyTestV3(IStrategy): return dataframe - def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe.loc[ ( @@ -147,7 +147,7 @@ class StrategyTestV3(IStrategy): return dataframe - def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe.loc[ ( ( diff --git a/tests/strategy/test_default_strategy.py b/tests/strategy/test_default_strategy.py index 7eb0faab5..a9d11e52f 100644 --- a/tests/strategy/test_default_strategy.py +++ b/tests/strategy/test_default_strategy.py @@ -13,8 +13,8 @@ def test_strategy_test_v3_structure(): assert hasattr(StrategyTestV3, 'stoploss') assert hasattr(StrategyTestV3, 'timeframe') assert hasattr(StrategyTestV3, 'populate_indicators') - assert hasattr(StrategyTestV3, 'populate_buy_trend') - assert hasattr(StrategyTestV3, 'populate_sell_trend') + assert hasattr(StrategyTestV3, 'populate_entry_trend') + assert hasattr(StrategyTestV3, 'populate_exit_trend') @pytest.mark.parametrize('is_short,side', [ diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index 4a01b7dec..18af215a3 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -477,7 +477,7 @@ def test_stop_loss_reached(default_conf, fee, profit, adjusted, expected, traili strategy.custom_stoploss = original_stopvalue -def test_custom_sell(default_conf, fee, caplog) -> None: +def test_custom_exit(default_conf, fee, caplog) -> None: strategy = StrategyResolver.load_strategy(default_conf) trade = Trade( @@ -499,7 +499,7 @@ def test_custom_sell(default_conf, fee, caplog) -> None: assert res.sell_flag is False assert res.sell_type == SellType.NONE - strategy.custom_sell = MagicMock(return_value=True) + strategy.custom_exit = MagicMock(return_value=True) res = strategy.should_exit(trade, 1, now, enter=False, exit_=False, low=None, high=None) @@ -507,7 +507,7 @@ def test_custom_sell(default_conf, fee, caplog) -> None: assert res.sell_type == SellType.CUSTOM_SELL assert res.sell_reason == 'custom_sell' - strategy.custom_sell = MagicMock(return_value='hello world') + strategy.custom_exit = MagicMock(return_value='hello world') res = strategy.should_exit(trade, 1, now, enter=False, exit_=False, @@ -517,14 +517,14 @@ def test_custom_sell(default_conf, fee, caplog) -> None: assert res.sell_reason == 'hello world' caplog.clear() - strategy.custom_sell = MagicMock(return_value='h' * 100) + strategy.custom_exit = MagicMock(return_value='h' * 100) res = strategy.should_exit(trade, 1, now, enter=False, exit_=False, low=None, high=None) assert res.sell_type == SellType.CUSTOM_SELL assert res.sell_flag is True assert res.sell_reason == 'h' * 64 - assert log_has_re('Custom sell reason returned from custom_sell is too long.*', caplog) + assert log_has_re('Custom sell reason returned from custom_exit is too long.*', caplog) @pytest.mark.parametrize('side', TRADE_SIDES) diff --git a/tests/strategy/test_strategy_loading.py b/tests/strategy/test_strategy_loading.py index 1de821146..7464c3330 100644 --- a/tests/strategy/test_strategy_loading.py +++ b/tests/strategy/test_strategy_loading.py @@ -144,6 +144,16 @@ def test_strategy_can_short(caplog, default_conf): assert isinstance(strat, IStrategy) +def test_strategy_implements_populate_entry(caplog, default_conf): + caplog.set_level(logging.INFO) + default_conf.update({ + 'strategy': "StrategyTestV2", + }) + default_conf['trading_mode'] = 'futures' + with pytest.raises(OperationalException, match="`populate_entry_trend` must be implemented."): + StrategyResolver.load_strategy(default_conf) + + def test_strategy_override_minimal_roi(caplog, default_conf): caplog.set_level(logging.INFO) default_conf.update({ @@ -381,6 +391,40 @@ def test_deprecate_populate_indicators(result, default_conf): in str(w[-1].message) +@pytest.mark.filterwarnings("ignore:deprecated") +def test_missing_implements(default_conf): + default_location = Path(__file__).parent / "strats/broken_strats" + default_conf.update({'strategy': 'TestStrategyNoImplements', + 'strategy_path': default_location}) + with pytest.raises(OperationalException, + match=r"`populate_entry_trend` or `populate_buy_trend`.*"): + StrategyResolver.load_strategy(default_conf) + + default_conf['strategy'] = 'TestStrategyNoImplementSell' + + with pytest.raises(OperationalException, + match=r"`populate_exit_trend` or `populate_sell_trend`.*"): + StrategyResolver.load_strategy(default_conf) + + # Futures mode is more strict ... + default_conf['trading_mode'] = 'futures' + + with pytest.raises(OperationalException, + match=r"`populate_exit_trend` must be implemented.*"): + StrategyResolver.load_strategy(default_conf) + + default_conf['strategy'] = 'TestStrategyNoImplements' + with pytest.raises(OperationalException, + match=r"`populate_entry_trend` must be implemented.*"): + StrategyResolver.load_strategy(default_conf) + + default_conf['strategy'] = 'TestStrategyImplementCustomSell' + + with pytest.raises(OperationalException, + match=r"Please migrate your implementation of `custom_sell`.*"): + StrategyResolver.load_strategy(default_conf) + + @pytest.mark.filterwarnings("ignore:deprecated") def test_call_deprecated_function(result, default_conf, caplog): default_location = Path(__file__).parent / "strats"