Repository: freqtrade/freqtrade
Pull request: Add buy signal name
## Summary
When use multiple buy signal, I (maybe we) need to know the buy signal that trigger a trade
Then on custom_sell, I able to use ```trade.buy_tag```

## What's new?
### Buy Tag on Strategy
Normal way to use populate_buy_trend

```
dataframe.loc[
        (
            (dataframe['rsi'] < 35) &
            (dataframe['volume'] > 0)
        ),
        'buy'] = 1
```


Use with custom buy_tag
```
dataframe.loc[
        (
            (dataframe['rsi'] < 35) &
            (dataframe['volume'] > 0)
        ),
        ['buy', 'buy_tag']] = (1, 'my_buy_signal_01')
```

Then on custom_sell just call it
```
trade.buy_tag
```

### Buy Tag on Telegram

When the trade has buy_tag
![image](https://user-images.githubusercontent.com/13866266/126539819-8d0d417f-78da-4e98-a7b7-a9b23b433c5a.png)
-------
![image](https://user-images.githubusercontent.com/13866266/126539762-02c12d46-4802-439e-b4c3-9f7bbed2008c.png)


closes #3081

Problem statement:
Tagging of buy & sell conditions 
## New feature request

Ability to tag buy and sell strategies and print what triggered the buy and sell in telegram bit output. 

## Example:
Having two dataframe.loc where we can tag a name to each. 
Dataframe.loc[
(
   (Condition)
), 'buytag'] = 'my tag1'

Dataframe.loc[
(
.   (Condition)
), 'buytag'] = 'my tag2'

Hints from issue discussion:
continuing the discussion started at Slack:
-- the idea itself is great. 👍 
-- I would like to keep the float as the return value from populate_buy/sell_trend methods. In the future, this value can be treated as the position size (i.e., 0.5 returned would mean "open the position, but with 0.5 amount of "normal" position size"). Discussible.
-- so in this case and due to this reasoning, another columns (like "buy_tag"/"sell_tag") should be used instead
-- helper functions, like `buy_condition(condition, "buy_tag")` can be added, which will incapsulate both `.loc` for the sell signal and setting the tag. This will, even without tag, would simplify the strategies, so that a user would not write those multiline `.loc` expressions explicitely.

one thing is for sure - it'll need to be documented well.

I guess it can easily be done using masks:

``` python
cond1 = (dataframe['rsi'] < 30) & (dataframe['volume'] > 0)
cond2 = (dataframe['macd'] > dataframe['macd_signal']) & (dataframe['volume'] > 0)

dataframe.loc[cond1, 'buy_tag'] = "rsi<30"
dataframe.loc[cond2, 'buy_tag'] = "macd_signal_cross"

# dataframe.loc[cond1 & cond2, 'buy_tag'] = "both-conditions"

# Define condition
dataframe.loc[cond1 | cond2, 'buy'] = 1

```
Note: this will NOT be a good strategy - so please don't even TRY to use this as it is just a quickly hacked together thing to demonstrate possible use of the `'buy_tag'`. 

Now obviously if both signals would apply the "2nd line" wins, so macd_signal_cross would apply.
This can be captured with the 3rd line - but that'll make it slightly difficult.

---

Now i'm not convinced about the "helper methods" - they won't give you much if you consider the above context.
You'll still need the masks (`cond1`, `cond2`) - you'll need to pass them (with the dataframe + the text) to the function - so i don't see any benefit while using the below

``` python
dataframe = buy_tag_function(dataframe, cond1, 'rsi<30')
dataframe = buy_tag_function(dataframe, cond1, 'macd_signal_cross')
# dataframe = buy_tag_function(dataframe, cond1 & cond2, 'both')

dataframe.loc[cond1 | cond2, 'buy'] = 1
```

To me, this is such an easy assignment that the function-call looks more complicated, as it hides the final column name.

This might seem tempting, but i'm not keen on debugging peoples strategies that used `sell_function()` instead of `buy_function()` - simply because it can be avoided by being explicit in what is happening - which is leaving the pandas-code as is - which is - considering the amount of characters needed - less verbose than the function.
does it make sense to such an addition?

```
# Indexes for backtest tuples
DATE_IDX = 0
BUY_IDX = 1
**BUY_TAG_IDX** = 1,5 (joking ofc :) it ll go +1 incrementally)
OPEN_IDX = 2
CLOSE_IDX = 3
SELL_IDX = 4
**SELL_TAG_IDX** = 4,5
LOW_IDX = 5
HIGH_IDX = 6
```

and i would ignore multiple condition hits and just go for the first hit condition. otherwise it ll become a condition table instead of a buy/sell tag.

@xmatthias s approach in the first code sample makes complete sense to me, even as it is in the sample. why were you against that sample code?

**Addition:** I see it was proposed for telegram but I would love to see these buy/sell tags on backtest results. it was even my initial trigger for my attention on the subject.
> why were you against that sample code?

noone rejected the proposal of using a "reason" column (naming not defined) - but it's not been important enough to anyone to spend time on either - and as such - it's an open enhancement. 

I've been against using helper-methods - as at least in the usecase / usage i came up with, it seems to be more complex than the "direct assignment" case.


I'm not sure about backtesting - it'll make backtesting-results again more complicated to read through - so while it may seem to be a good idea, it'll take away the "one glance" determination if it's good or bad.

You can sure give it a try - if it's done in a concise, unobstructive way, we can for sure include it. If it clutters results too much, we might wanna put it behind an optional command line flag (but i'd leave that open for the moment).

As said before - i assume you're proposing to make a PR - as i don't see this as anything important for the moment, and as such will not be spending time on it myself.

Commit message: add test and migration

Patch to review:
diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py
index 78163d86f1d..bcf68d62230 100644
--- a/freqtrade/enums/__init__.py
+++ b/freqtrade/enums/__init__.py
@@ -2,5 +2,5 @@
 from freqtrade.enums.rpcmessagetype import RPCMessageType
 from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode
 from freqtrade.enums.selltype import SellType
-from freqtrade.enums.signaltype import SignalType
+from freqtrade.enums.signaltype import SignalType, SignalNameType
 from freqtrade.enums.state import State
diff --git a/freqtrade/enums/signaltype.py b/freqtrade/enums/signaltype.py
index d636f378a1e..6f29699f10e 100644
--- a/freqtrade/enums/signaltype.py
+++ b/freqtrade/enums/signaltype.py
@@ -7,3 +7,10 @@ class SignalType(Enum):
     """
     BUY = "buy"
     SELL = "sell"
+
+
+class SignalNameType(Enum):
+    """
+    Enum for signal name
+    """
+    BUY_SIGNAL_NAME = "buy_signal_name"
diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py
index 7a7371357ab..852d5bf782d 100644
--- a/freqtrade/freqtradebot.py
+++ b/freqtrade/freqtradebot.py
@@ -420,7 +420,7 @@ def create_trade(self, pair: str) -> bool:
             return False
 
         # running get_signal on historical data fetched
-        (buy, sell) = self.strategy.get_signal(pair, self.strategy.timeframe, analyzed_df)
+        (buy, sell, buy_signal_name) = self.strategy.get_signal(pair, self.strategy.timeframe, analyzed_df)
 
         if buy and not sell:
             stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge)
@@ -435,11 +435,11 @@ def create_trade(self, pair: str) -> bool:
             if ((bid_check_dom.get('enabled', False)) and
                     (bid_check_dom.get('bids_to_ask_delta', 0) > 0)):
                 if self._check_depth_of_market_buy(pair, bid_check_dom):
-                    return self.execute_buy(pair, stake_amount)
+                    return self.execute_buy(pair, stake_amount, buy_signal_name=buy_signal_name)
                 else:
                     return False
 
-            return self.execute_buy(pair, stake_amount)
+            return self.execute_buy(pair, stake_amount, buy_signal_name=buy_signal_name)
         else:
             return False
 
@@ -468,7 +468,7 @@ def _check_depth_of_market_buy(self, pair: str, conf: Dict) -> bool:
             return False
 
     def execute_buy(self, pair: str, stake_amount: float, price: Optional[float] = None,
-                    forcebuy: bool = False) -> bool:
+                    forcebuy: bool = False, buy_signal_name: str = '') -> bool:
         """
         Executes a limit buy for the given pair
         :param pair: pair for which we want to create a LIMIT_BUY
@@ -562,6 +562,7 @@ def execute_buy(self, pair: str, stake_amount: float, price: Optional[float] = N
             exchange=self.exchange.id,
             open_order_id=order_id,
             strategy=self.strategy.get_strategy_name(),
+            buy_signal_name=buy_signal_name,
             timeframe=timeframe_to_minutes(self.config['timeframe'])
         )
         trade.orders.append(order_obj)
@@ -689,7 +690,7 @@ def handle_trade(self, trade: Trade) -> bool:
             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)
+            (buy, sell, _) = self.strategy.get_signal(trade.pair, self.strategy.timeframe, analyzed_df)
 
         logger.debug('checking sell')
         sell_rate = self.exchange.get_sell_rate(trade.pair, True)
diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py
index 7c6b7cbc3e7..2b8bd1e2b88 100644
--- a/freqtrade/optimize/backtesting.py
+++ b/freqtrade/optimize/backtesting.py
@@ -42,6 +42,7 @@
 SELL_IDX = 4
 LOW_IDX = 5
 HIGH_IDX = 6
+BUY_SIGNAL_NAME_IDX = 7
 
 
 class Backtesting:
@@ -189,7 +190,7 @@ def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tupl
         """
         # Every change to this headers list must evaluate further usages of the resulting tuple
         # and eventually change the constants for indexes at the top
-        headers = ['date', 'buy', 'open', 'close', 'sell', 'low', 'high']
+        headers = ['date', 'buy', 'open', 'close', 'sell', 'low', 'high', 'buy_signal_name']
         data: Dict = {}
         # Create dict with data
         for pair, pair_data in processed.items():
@@ -332,6 +333,7 @@ def _enter_trade(self, pair: str, row: List) -> Optional[LocalTrade]:
                 fee_open=self.fee,
                 fee_close=self.fee,
                 is_open=True,
+                buy_signal_name=row[BUY_SIGNAL_NAME_IDX],
                 exchange='backtesting',
             )
             return trade
diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py
index 00c9b91ebbd..4fa1cb74270 100644
--- a/freqtrade/persistence/migrations.py
+++ b/freqtrade/persistence/migrations.py
@@ -47,6 +47,7 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
     min_rate = get_column_def(cols, 'min_rate', 'null')
     sell_reason = get_column_def(cols, 'sell_reason', 'null')
     strategy = get_column_def(cols, 'strategy', 'null')
+    buy_signal_name = get_column_def(cols, 'buy_signal_name', 'null')
     # If ticker-interval existed use that, else null.
     if has_column(cols, 'ticker_interval'):
         timeframe = get_column_def(cols, 'timeframe', 'ticker_interval')
@@ -80,7 +81,7 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
             stake_amount, amount, amount_requested, open_date, close_date, open_order_id,
             stop_loss, stop_loss_pct, initial_stop_loss, initial_stop_loss_pct,
             stoploss_order_id, stoploss_last_update,
-            max_rate, min_rate, sell_reason, sell_order_status, strategy,
+            max_rate, min_rate, sell_reason, sell_order_status, strategy, buy_signal_name,
             timeframe, open_trade_value, close_profit_abs
             )
         select id, lower(exchange),
@@ -103,7 +104,7 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
             {stoploss_order_id} stoploss_order_id, {stoploss_last_update} stoploss_last_update,
             {max_rate} max_rate, {min_rate} min_rate, {sell_reason} sell_reason,
             {sell_order_status} sell_order_status,
-            {strategy} strategy, {timeframe} timeframe,
+            {strategy} strategy, {buy_signal_name} buy_signal_name, {timeframe} timeframe,
             {open_trade_value} open_trade_value, {close_profit_abs} close_profit_abs
             from {table_back_name}
             """))
@@ -160,7 +161,7 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
     table_back_name = get_backup_name(tabs, 'trades_bak')
 
     # Check for latest column
-    if not has_column(cols, 'open_trade_value'):
+    if not has_column(cols, 'open_trade_value') or not has_column(cols, 'buy_signal_name'):
         logger.info(f'Running database migration for trades - backup: {table_back_name}')
         migrate_trades_table(decl_base, inspector, engine, table_back_name, cols)
         # Reread columns - the above recreated the table!
diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py
index b4c2991203f..8d77ac27a21 100644
--- a/freqtrade/persistence/models.py
+++ b/freqtrade/persistence/models.py
@@ -257,6 +257,7 @@ class LocalTrade():
     sell_reason: str = ''
     sell_order_status: str = ''
     strategy: str = ''
+    buy_signal_name: str = ''
     timeframe: Optional[int] = None
 
     def __init__(self, **kwargs):
@@ -288,6 +289,7 @@ def to_json(self) -> Dict[str, Any]:
             'amount_requested': round(self.amount_requested, 8) if self.amount_requested else None,
             'stake_amount': round(self.stake_amount, 8),
             'strategy': self.strategy,
+            'buy_signal_name': self.buy_signal_name,
             'timeframe': self.timeframe,
 
             'fee_open': self.fee_open,
@@ -703,6 +705,7 @@ class Trade(_DECL_BASE, LocalTrade):
     sell_reason = Column(String(100), nullable=True)
     sell_order_status = Column(String(100), nullable=True)
     strategy = Column(String(100), nullable=True)
+    buy_signal_name = Column(String(100), nullable=True)
     timeframe = Column(Integer, nullable=True)
 
     def __init__(self, **kwargs):
diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py
index 7aa7e57d96c..cbd19e15652 100644
--- a/freqtrade/strategy/interface.py
+++ b/freqtrade/strategy/interface.py
@@ -13,7 +13,7 @@
 
 from freqtrade.constants import ListPairsWithTimeframes
 from freqtrade.data.dataprovider import DataProvider
-from freqtrade.enums import SellType, SignalType
+from freqtrade.enums import SellType, SignalType, SignalNameType
 from freqtrade.exceptions import OperationalException, StrategyError
 from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
 from freqtrade.exchange.exchange import timeframe_to_next_date
@@ -478,7 +478,7 @@ def assert_df(self, dataframe: DataFrame, df_len: int, df_close: float, df_date:
             else:
                 raise StrategyError(message)
 
-    def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) -> Tuple[bool, bool]:
+    def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) -> Tuple[bool, bool, str]:
         """
         Calculates current signal based based on the buy / sell columns of the dataframe.
         Used by Bot to get the signal to buy or sell
@@ -489,7 +489,7 @@ def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) -> Tuple[b
         """
         if not isinstance(dataframe, DataFrame) or dataframe.empty:
             logger.warning(f'Empty candle (OHLCV) data for pair {pair}')
-            return False, False
+            return False, False, ''
 
         latest_date = dataframe['date'].max()
         latest = dataframe.loc[dataframe['date'] == latest_date].iloc[-1]
@@ -504,9 +504,11 @@ def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) -> Tuple[b
                 'Outdated history for pair %s. Last tick is %s minutes old',
                 pair, int((arrow.utcnow() - latest_date).total_seconds() // 60)
             )
-            return False, False
+            return False, False, ''
 
-        (buy, sell) = latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1
+        (buy, sell, buy_signal_name) = latest[SignalType.BUY.value] == 1,\
+            latest[SignalType.SELL.value] == 1,\
+            latest.get(SignalNameType.BUY_SIGNAL_NAME.value, '')
         logger.debug('trigger: %s (pair=%s) buy=%s sell=%s',
                      latest['date'], pair, str(buy), str(sell))
         timeframe_seconds = timeframe_to_seconds(timeframe)
@@ -514,8 +516,8 @@ def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) -> Tuple[b
                                       current_time=datetime.now(timezone.utc),
                                       timeframe_seconds=timeframe_seconds,
                                       buy=buy):
-            return False, sell
-        return buy, sell
+            return False, sell, buy_signal_name
+        return buy, sell, buy_signal_name
 
     def ignore_expired_candle(self, latest_date: datetime, current_time: datetime,
                               timeframe_seconds: int, buy: bool):
diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py
index 2beb4465d53..c02a43e720b 100644
--- a/tests/strategy/test_interface.py
+++ b/tests/strategy/test_interface.py
@@ -37,15 +37,20 @@ def test_returns_latest_signal(mocker, default_conf, ohlcv_history):
     mocked_history['buy'] = 0
     mocked_history.loc[1, 'sell'] = 1
 
-    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, True)
+    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, True, '')
     mocked_history.loc[1, 'sell'] = 0
     mocked_history.loc[1, 'buy'] = 1
 
-    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (True, False)
+    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (True, False, '')
     mocked_history.loc[1, 'sell'] = 0
     mocked_history.loc[1, 'buy'] = 0
 
-    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, False)
+    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, False, '')
+    mocked_history.loc[1, 'sell'] = 0
+    mocked_history.loc[1, 'buy'] = 1
+    mocked_history.loc[1, 'buy_signal_name'] = 'buy_signal_01'
+
+    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (True, False, 'buy_signal_01')
 
 
 def test_analyze_pair_empty(default_conf, mocker, caplog, ohlcv_history):
@@ -62,15 +67,15 @@ def test_analyze_pair_empty(default_conf, mocker, caplog, ohlcv_history):
 
 
 def test_get_signal_empty(default_conf, mocker, caplog):
-    assert (False, False) == _STRATEGY.get_signal('foo', default_conf['timeframe'], DataFrame())
+    assert (False, False, '') == _STRATEGY.get_signal('foo', default_conf['timeframe'], DataFrame())
     assert log_has('Empty candle (OHLCV) data for pair foo', caplog)
     caplog.clear()
 
-    assert (False, False) == _STRATEGY.get_signal('bar', default_conf['timeframe'], None)
+    assert (False, False, '') == _STRATEGY.get_signal('bar', default_conf['timeframe'], None)
     assert log_has('Empty candle (OHLCV) data for pair bar', caplog)
     caplog.clear()
 
-    assert (False, False) == _STRATEGY.get_signal('baz', default_conf['timeframe'], DataFrame([]))
+    assert (False, False, '') == _STRATEGY.get_signal('baz', default_conf['timeframe'], DataFrame([]))
     assert log_has('Empty candle (OHLCV) data for pair baz', caplog)
 
 
@@ -106,7 +111,7 @@ def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history):
     caplog.set_level(logging.INFO)
     mocker.patch.object(_STRATEGY, 'assert_df')
 
-    assert (False, False) == _STRATEGY.get_signal('xyz', default_conf['timeframe'], mocked_history)
+    assert (False, False, '') == _STRATEGY.get_signal('xyz', default_conf['timeframe'], mocked_history)
     assert log_has('Outdated history for pair xyz. Last tick is 16 minutes old', caplog)
 
 
diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py
index 99e11e89331..2309c96f080 100644
--- a/tests/test_freqtradebot.py
+++ b/tests/test_freqtradebot.py
@@ -736,7 +736,7 @@ def _refresh_whitelist(list):
         refresh_latest_ohlcv=refresh_mock,
     )
     inf_pairs = MagicMock(return_value=[("BTC/ETH", '1m'), ("ETH/USDT", "1h")])
-    mocker.patch('freqtrade.strategy.interface.IStrategy.get_signal', return_value=(False, False))
+    mocker.patch('freqtrade.strategy.interface.IStrategy.get_signal', return_value=(False, False, ''))
     mocker.patch('time.sleep', return_value=None)
 
     freqtrade = FreqtradeBot(default_conf)


Code review comments:
diff_hunk: @@ -160,7 +161,7 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
     table_back_name = get_backup_name(tabs, 'trades_bak')
 
     # Check for latest column
-    if not has_column(cols, 'open_trade_value'):
+    if not has_column(cols, 'open_trade_value') or not has_column(cols, 'buy_signal_name'):
path: freqtrade/persistence/migrations.py
text: @user1:
there can only be one last column (last added). If that last column exists, no migration is necessary.
As such - this should look (roughly?) as follows:
```suggestion
    if not has_column(cols, 'buy_signal_name'):
```