diff --git a/freqtrade/analyze.py b/freqtrade/analyze.py index 70bf40936..a1ed09824 100644 --- a/freqtrade/analyze.py +++ b/freqtrade/analyze.py @@ -1,121 +1,190 @@ """ Functions to analyze ticker data with indicators and produce buy and sell signals """ -import logging -from datetime import timedelta -from enum import Enum -from typing import Dict, List - import arrow +from datetime import datetime, timedelta +from enum import Enum from pandas import DataFrame, to_datetime - +from typing import Dict, List from freqtrade.exchange import get_ticker_history +from freqtrade.logger import Logger from freqtrade.strategy.strategy import Strategy - -logger = logging.getLogger(__name__) +from freqtrade.persistence import Trade class SignalType(Enum): - """ Enum to distinguish between buy and sell signals """ + """ + Enum to distinguish between buy and sell signals + """ BUY = "buy" SELL = "sell" -def parse_ticker_dataframe(ticker: list) -> DataFrame: +class Analyze(object): """ - Analyses the trend for the given ticker history - :param ticker: See exchange.get_ticker_history - :return: DataFrame + Analyze class contains everything the bot need to determine if the situation is good for + buying or selling. """ - columns = {'C': 'close', 'V': 'volume', 'O': 'open', 'H': 'high', 'L': 'low', 'T': 'date'} - frame = DataFrame(ticker) \ - .rename(columns=columns) - if 'BV' in frame: - frame.drop('BV', 1, inplace=True) - frame['date'] = to_datetime(frame['date'], utc=True, infer_datetime_format=True) - frame.sort_values('date', inplace=True) - return frame + def __init__(self, config: dict) -> None: + """ + Init Analyze + :param config: Bot configuration (use the one from Configuration()) + """ + self.logger = Logger(name=__name__).get_logger() + self.config = config + self.strategy = Strategy() + self.strategy.init(self.config) -def populate_indicators(dataframe: DataFrame) -> DataFrame: - """ - Adds several different TA indicators to the given DataFrame + @staticmethod + def parse_ticker_dataframe(ticker: list) -> DataFrame: + """ + Analyses the trend for the given ticker history + :param ticker: See exchange.get_ticker_history + :return: DataFrame + """ + columns = {'C': 'close', 'V': 'volume', 'O': 'open', 'H': 'high', 'L': 'low', 'T': 'date'} + frame = DataFrame(ticker) \ + .rename(columns=columns) + if 'BV' in frame: + frame.drop('BV', 1, inplace=True) + frame['date'] = to_datetime(frame['date'], utc=True, infer_datetime_format=True) + frame.sort_values('date', inplace=True) + return frame - Performance Note: For the best performance be frugal on the number of indicators - you are using. Let uncomment only the indicator you are using in your strategies - or your hyperopt configuration, otherwise you will waste your memory and CPU usage. - """ - strategy = Strategy() - return strategy.populate_indicators(dataframe=dataframe) + def populate_indicators(self, dataframe: DataFrame) -> DataFrame: + """ + Adds several different TA indicators to the given DataFrame + Performance Note: For the best performance be frugal on the number of indicators + you are using. Let uncomment only the indicator you are using in your strategies + or your hyperopt configuration, otherwise you will waste your memory and CPU usage. + """ -def populate_buy_trend(dataframe: DataFrame) -> DataFrame: - """ - Based on TA indicators, populates the buy signal for the given dataframe - :param dataframe: DataFrame - :return: DataFrame with buy column - """ - strategy = Strategy() - return strategy.populate_buy_trend(dataframe=dataframe) + return self.strategy.populate_indicators(dataframe=dataframe) + def populate_buy_trend(self, dataframe: DataFrame) -> DataFrame: + """ + Based on TA indicators, populates the buy signal for the given dataframe + :param dataframe: DataFrame + :return: DataFrame with buy column + """ + return self.strategy.populate_buy_trend(dataframe=dataframe) -def populate_sell_trend(dataframe: DataFrame) -> DataFrame: - """ - Based on TA indicators, populates the sell signal for the given dataframe - :param dataframe: DataFrame - :return: DataFrame with buy column - """ - strategy = Strategy() - return strategy.populate_sell_trend(dataframe=dataframe) + def populate_sell_trend(self, dataframe: DataFrame) -> DataFrame: + """ + Based on TA indicators, populates the sell signal for the given dataframe + :param dataframe: DataFrame + :return: DataFrame with buy column + """ + return self.strategy.populate_sell_trend(dataframe=dataframe) + def analyze_ticker(self, ticker_history: List[Dict]) -> DataFrame: + """ + Parses the given ticker history and returns a populated DataFrame + add several TA indicators and buy signal to it + :return DataFrame with ticker data and indicator data + """ + dataframe = self.parse_ticker_dataframe(ticker_history) + dataframe = self.populate_indicators(dataframe) + dataframe = self.populate_buy_trend(dataframe) + dataframe = self.populate_sell_trend(dataframe) + return dataframe -def analyze_ticker(ticker_history: List[Dict]) -> DataFrame: - """ - Parses the given ticker history and returns a populated DataFrame - add several TA indicators and buy signal to it - :return DataFrame with ticker data and indicator data - """ - dataframe = parse_ticker_dataframe(ticker_history) - dataframe = populate_indicators(dataframe) - dataframe = populate_buy_trend(dataframe) - dataframe = populate_sell_trend(dataframe) - return dataframe + # FIX: Maybe return False, if an error has occured, + # Otherwise we might mask an error as an non-signal-scenario + def get_signal(self, pair: str, interval: int) -> (bool, bool): + """ + Calculates current signal based several technical analysis indicators + :param pair: pair in format BTC_ANT or BTC-ANT + :return: (Buy, Sell) A bool-tuple indicating buy/sell signal + """ + ticker_hist = get_ticker_history(pair, interval) + if not ticker_hist: + self.logger.warning('Empty ticker history for pair %s', pair) + return (False, False) # return False ? + try: + dataframe = self.analyze_ticker(ticker_hist) + except ValueError as error: + self.logger.warning( + 'Unable to analyze ticker for pair %s: %s', + pair, + str(error) + ) + return (False, False) # return False ? + except Exception as error: + self.logger.exception( + 'Unexpected error when analyzing ticker for pair %s: %s', + pair, + str(error) + ) + return (False, False) # return False ? -# FIX: Maybe return False, if an error has occured, -# Otherwise we might mask an error as an non-signal-scenario -def get_signal(pair: str, interval: int) -> (bool, bool): - """ - Calculates current signal based several technical analysis indicators - :param pair: pair in format BTC_ANT or BTC-ANT - :return: (Buy, Sell) A bool-tuple indicating buy/sell signal - """ - ticker_hist = get_ticker_history(pair, interval) - if not ticker_hist: - logger.warning('Empty ticker history for pair %s', pair) - return (False, False) # return False ? + if dataframe.empty: + self.logger.warning('Empty dataframe for pair %s', pair) + return (False, False) # return False ? - try: - dataframe = analyze_ticker(ticker_hist) - except ValueError as ex: - logger.warning('Unable to analyze ticker for pair %s: %s', pair, str(ex)) - return (False, False) # return False ? - except Exception as ex: - logger.exception('Unexpected error when analyzing ticker for pair %s: %s', pair, str(ex)) - return (False, False) # return False ? + latest = dataframe.iloc[-1] - if dataframe.empty: - logger.warning('Empty dataframe for pair %s', pair) - return (False, False) # return False ? + # Check if dataframe is out of date + signal_date = arrow.get(latest['date']) + if signal_date < arrow.now() - timedelta(minutes=(interval + 5)): + self.logger.warning('Too old dataframe for pair %s', pair) + return (False, False) # return False ? - latest = dataframe.iloc[-1] + (buy, sell) = latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1 + self.logger.debug( + 'trigger: %s (pair=%s) buy=%s sell=%s', + latest['date'], + pair, + str(buy), + str(sell) + ) + return (buy, sell) - # Check if dataframe is out of date - signal_date = arrow.get(latest['date']) - if signal_date < arrow.now() - timedelta(minutes=(interval + 5)): - logger.warning('Too old dataframe for pair %s', pair) - return (False, False) # return False ? + def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool, sell: bool) -> bool: + """ + This function evaluate if on the condition required to trigger a sell has been reached + if the threshold is reached and updates the trade record. + :return: True if trade should be sold, False otherwise + """ + # Check if minimal roi has been reached and no longer in buy conditions (avoiding a fee) + if self.min_roi_reached(trade=trade, current_rate=rate, current_time=date): + self.logger.debug('Executing sell due to ROI ...') + return True - (buy, sell) = latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1 - logger.debug('trigger: %s (pair=%s) buy=%s sell=%s', latest['date'], pair, str(buy), str(sell)) - return (buy, sell) + # Experimental: Check if the trade is profitable before selling it (avoid selling at loss) + if self.config.get('experimental', {}).get('sell_profit_only', False): + self.logger.debug('Checking if trade is profitable ...') + if trade.calc_profit(rate=rate) <= 0: + return False + + if sell and not buy and self.config.get('experimental', {}).get('use_sell_signal', False): + self.logger.debug('Executing sell due to sell signal ...') + return True + + return False + + def min_roi_reached(self, trade: Trade, current_rate: float, current_time: datetime) -> bool: + """ + Based an earlier trade and current price and ROI configuration, decides whether bot should + sell + :return True if bot should sell at current rate + """ + current_profit = trade.calc_profit_percent(current_rate) + if self.strategy.stoploss is not None and current_profit < float(self.strategy.stoploss): + self.logger.debug('Stop loss hit.') + return True + + # Check if time matches and current rate is above threshold + time_diff = (current_time - trade.open_date).total_seconds() / 60 + for duration, threshold in sorted(self.strategy.minimal_roi.items()): + if time_diff > float(duration) and current_profit > threshold: + return True + + self.logger.debug( + 'Threshold not reached. (cur_profit: %1.2f%%)', + float(current_profit) * 100.0 + ) + return False diff --git a/freqtrade/tests/test_analyze.py b/freqtrade/tests/test_analyze.py index 41a6c1c2f..3c1687ab3 100644 --- a/freqtrade/tests/test_analyze.py +++ b/freqtrade/tests/test_analyze.py @@ -1,16 +1,45 @@ # pragma pylint: disable=missing-docstring, C0103 + +""" +Unit test file for analyse.py +""" + import datetime from unittest.mock import MagicMock - -import arrow import logging +import arrow from pandas import DataFrame import freqtrade.tests.conftest as tt # test tools -from freqtrade.analyze import (get_signal, parse_ticker_dataframe, - populate_buy_trend, populate_indicators, - populate_sell_trend) -from freqtrade.strategy.strategy import Strategy +from freqtrade.analyze import Analyze, SignalType + + +# Avoid to reinit the same object again and again +_ANALYZE = Analyze({'strategy': 'default_strategy'}) + + +def test_signaltype_object() -> None: + """ + Test the SignalType object has the mandatory Constants + :return: None + """ + assert hasattr(SignalType, 'BUY') + assert hasattr(SignalType, 'SELL') + + +def test_analyze_object() -> None: + """ + Test the Analyze object has the mandatory methods + :return: None + """ + assert hasattr(Analyze, 'parse_ticker_dataframe') + assert hasattr(Analyze, 'populate_indicators') + assert hasattr(Analyze, 'populate_buy_trend') + assert hasattr(Analyze, 'populate_sell_trend') + assert hasattr(Analyze, 'analyze_ticker') + assert hasattr(Analyze, 'get_signal') + assert hasattr(Analyze, 'should_sell') + assert hasattr(Analyze, 'min_roi_reached') def test_dataframe_correct_columns(result): @@ -18,71 +47,75 @@ def test_dataframe_correct_columns(result): ['close', 'high', 'low', 'open', 'date', 'volume'] -def test_dataframe_correct_length(result): - # no idea what this check truly does - should we just remove it? - assert len(result.index) == 14397 - - def test_populates_buy_trend(result): # Load the default strategy for the unit test, because this logic is done in main.py - Strategy().init({'strategy': 'default_strategy'}) - - dataframe = populate_buy_trend(populate_indicators(result)) + dataframe = _ANALYZE.populate_buy_trend(_ANALYZE.populate_indicators(result)) assert 'buy' in dataframe.columns def test_populates_sell_trend(result): # Load the default strategy for the unit test, because this logic is done in main.py - Strategy().init({'strategy': 'default_strategy'}) - - dataframe = populate_sell_trend(populate_indicators(result)) + dataframe = _ANALYZE.populate_sell_trend(_ANALYZE.populate_indicators(result)) assert 'sell' in dataframe.columns def test_returns_latest_buy_signal(mocker): mocker.patch('freqtrade.analyze.get_ticker_history', return_value=MagicMock()) - mocker.patch( - 'freqtrade.analyze.analyze_ticker', - return_value=DataFrame([{'buy': 1, 'sell': 0, 'date': arrow.utcnow()}]) - ) - assert get_signal('BTC-ETH', 5) == (True, False) - mocker.patch( - 'freqtrade.analyze.analyze_ticker', - return_value=DataFrame([{'buy': 0, 'sell': 1, 'date': arrow.utcnow()}]) + mocker.patch.multiple( + 'freqtrade.analyze.Analyze', + analyze_ticker=MagicMock( + return_value=DataFrame([{'buy': 1, 'sell': 0, 'date': arrow.utcnow()}]) + ) ) - assert get_signal('BTC-ETH', 5) == (False, True) + assert _ANALYZE.get_signal('BTC-ETH', 5) == (True, False) + + mocker.patch.multiple( + 'freqtrade.analyze.Analyze', + analyze_ticker=MagicMock( + return_value=DataFrame([{'buy': 0, 'sell': 1, 'date': arrow.utcnow()}]) + ) + ) + assert _ANALYZE.get_signal('BTC-ETH', 5) == (False, True) def test_returns_latest_sell_signal(mocker): mocker.patch('freqtrade.analyze.get_ticker_history', return_value=MagicMock()) - mocker.patch( - 'freqtrade.analyze.analyze_ticker', - return_value=DataFrame([{'sell': 1, 'buy': 0, 'date': arrow.utcnow()}]) + mocker.patch.multiple( + 'freqtrade.analyze.Analyze', + analyze_ticker=MagicMock( + return_value=DataFrame([{'sell': 1, 'buy': 0, 'date': arrow.utcnow()}]) + ) ) - assert get_signal('BTC-ETH', 5) == (False, True) - mocker.patch( - 'freqtrade.analyze.analyze_ticker', - return_value=DataFrame([{'sell': 0, 'buy': 1, 'date': arrow.utcnow()}]) + assert _ANALYZE.get_signal('BTC-ETH', 5) == (False, True) + + mocker.patch.multiple( + 'freqtrade.analyze.Analyze', + analyze_ticker=MagicMock( + return_value=DataFrame([{'sell': 0, 'buy': 1, 'date': arrow.utcnow()}]) + ) ) - assert get_signal('BTC-ETH', 5) == (True, False) + assert _ANALYZE.get_signal('BTC-ETH', 5) == (True, False) def test_get_signal_empty(default_conf, mocker, caplog): caplog.set_level(logging.INFO) mocker.patch('freqtrade.analyze.get_ticker_history', return_value=None) - assert (False, False) == get_signal('foo', int(default_conf['ticker_interval'])) - assert tt.log_has('Empty ticker history for pair foo', - caplog.record_tuples) + assert (False, False) == _ANALYZE.get_signal('foo', int(default_conf['ticker_interval'])) + assert tt.log_has('Empty ticker history for pair foo', caplog.record_tuples) def test_get_signal_exception_valueerror(default_conf, mocker, caplog): caplog.set_level(logging.INFO) mocker.patch('freqtrade.analyze.get_ticker_history', return_value=1) - mocker.patch('freqtrade.analyze.analyze_ticker', - side_effect=ValueError('xyz')) - assert (False, False) == get_signal('foo', int(default_conf['ticker_interval'])) + mocker.patch.multiple( + 'freqtrade.analyze.Analyze', + analyze_ticker=MagicMock( + side_effect=ValueError('xyz') + ) + ) + assert (False, False) == _ANALYZE.get_signal('foo', int(default_conf['ticker_interval'])) assert tt.log_has('Unable to analyze ticker for pair foo: xyz', caplog.record_tuples) @@ -90,8 +123,13 @@ def test_get_signal_exception_valueerror(default_conf, mocker, caplog): def test_get_signal_empty_dataframe(default_conf, mocker, caplog): caplog.set_level(logging.INFO) mocker.patch('freqtrade.analyze.get_ticker_history', return_value=1) - mocker.patch('freqtrade.analyze.analyze_ticker', return_value=DataFrame([])) - assert (False, False) == get_signal('xyz', int(default_conf['ticker_interval'])) + mocker.patch.multiple( + 'freqtrade.analyze.Analyze', + analyze_ticker=MagicMock( + return_value=DataFrame([]) + ) + ) + assert (False, False) == _ANALYZE.get_signal('xyz', int(default_conf['ticker_interval'])) assert tt.log_has('Empty dataframe for pair xyz', caplog.record_tuples) @@ -102,27 +140,36 @@ def test_get_signal_old_dataframe(default_conf, mocker, caplog): # FIX: The get_signal function has hardcoded 10, which we must inturn hardcode oldtime = arrow.utcnow() - datetime.timedelta(minutes=11) ticks = DataFrame([{'buy': 1, 'date': oldtime}]) - mocker.patch('freqtrade.analyze.analyze_ticker', return_value=DataFrame(ticks)) - assert (False, False) == get_signal('xyz', int(default_conf['ticker_interval'])) + mocker.patch.multiple( + 'freqtrade.analyze.Analyze', + analyze_ticker=MagicMock( + return_value=DataFrame(ticks) + ) + ) + assert (False, False) == _ANALYZE.get_signal('xyz', int(default_conf['ticker_interval'])) assert tt.log_has('Too old dataframe for pair xyz', caplog.record_tuples) def test_get_signal_handles_exceptions(mocker): mocker.patch('freqtrade.analyze.get_ticker_history', return_value=MagicMock()) - mocker.patch('freqtrade.analyze.analyze_ticker', - side_effect=Exception('invalid ticker history ')) + mocker.patch.multiple( + 'freqtrade.analyze.Analyze', + analyze_ticker=MagicMock( + side_effect=Exception('invalid ticker history ') + ) + ) - assert get_signal('BTC-ETH', 5) == (False, False) + assert _ANALYZE.get_signal('BTC-ETH', 5) == (False, False) def test_parse_ticker_dataframe(ticker_history, ticker_history_without_bv): columns = ['close', 'high', 'low', 'open', 'date', 'volume'] # Test file with BV data - dataframe = parse_ticker_dataframe(ticker_history) + dataframe = Analyze.parse_ticker_dataframe(ticker_history) assert dataframe.columns.tolist() == columns # Test file without BV data - dataframe = parse_ticker_dataframe(ticker_history_without_bv) + dataframe = Analyze.parse_ticker_dataframe(ticker_history_without_bv) assert dataframe.columns.tolist() == columns