Move Analyze to a class

This commit is contained in:
Gerald Lonlas 2018-02-04 00:28:02 -08:00
parent e025dc0dba
commit a8b8ab20b7
2 changed files with 254 additions and 138 deletions

View File

@ -1,121 +1,190 @@
""" """
Functions to analyze ticker data with indicators and produce buy and sell signals 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 import arrow
from datetime import datetime, timedelta
from enum import Enum
from pandas import DataFrame, to_datetime from pandas import DataFrame, to_datetime
from typing import Dict, List
from freqtrade.exchange import get_ticker_history from freqtrade.exchange import get_ticker_history
from freqtrade.logger import Logger
from freqtrade.strategy.strategy import Strategy from freqtrade.strategy.strategy import Strategy
from freqtrade.persistence import Trade
logger = logging.getLogger(__name__)
class SignalType(Enum): class SignalType(Enum):
""" Enum to distinguish between buy and sell signals """ """
Enum to distinguish between buy and sell signals
"""
BUY = "buy" BUY = "buy"
SELL = "sell" SELL = "sell"
def parse_ticker_dataframe(ticker: list) -> DataFrame: class Analyze(object):
""" """
Analyses the trend for the given ticker history Analyze class contains everything the bot need to determine if the situation is good for
:param ticker: See exchange.get_ticker_history buying or selling.
:return: DataFrame
""" """
columns = {'C': 'close', 'V': 'volume', 'O': 'open', 'H': 'high', 'L': 'low', 'T': 'date'} def __init__(self, config: dict) -> None:
frame = DataFrame(ticker) \ """
.rename(columns=columns) Init Analyze
if 'BV' in frame: :param config: Bot configuration (use the one from Configuration())
frame.drop('BV', 1, inplace=True) """
frame['date'] = to_datetime(frame['date'], utc=True, infer_datetime_format=True) self.logger = Logger(name=__name__).get_logger()
frame.sort_values('date', inplace=True)
return frame
self.config = config
self.strategy = Strategy()
self.strategy.init(self.config)
def populate_indicators(dataframe: DataFrame) -> DataFrame: @staticmethod
""" def parse_ticker_dataframe(ticker: list) -> DataFrame:
Adds several different TA indicators to the given 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 def populate_indicators(self, dataframe: DataFrame) -> DataFrame:
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. Adds several different TA indicators to the given DataFrame
"""
strategy = Strategy()
return strategy.populate_indicators(dataframe=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: return self.strategy.populate_indicators(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)
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: def populate_sell_trend(self, dataframe: DataFrame) -> DataFrame:
""" """
Based on TA indicators, populates the sell signal for the given dataframe Based on TA indicators, populates the sell signal for the given dataframe
:param dataframe: DataFrame :param dataframe: DataFrame
:return: DataFrame with buy column :return: DataFrame with buy column
""" """
strategy = Strategy() return self.strategy.populate_sell_trend(dataframe=dataframe)
return 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: # FIX: Maybe return False, if an error has occured,
""" # Otherwise we might mask an error as an non-signal-scenario
Parses the given ticker history and returns a populated DataFrame def get_signal(self, pair: str, interval: int) -> (bool, bool):
add several TA indicators and buy signal to it """
:return DataFrame with ticker data and indicator data Calculates current signal based several technical analysis indicators
""" :param pair: pair in format BTC_ANT or BTC-ANT
dataframe = parse_ticker_dataframe(ticker_history) :return: (Buy, Sell) A bool-tuple indicating buy/sell signal
dataframe = populate_indicators(dataframe) """
dataframe = populate_buy_trend(dataframe) ticker_hist = get_ticker_history(pair, interval)
dataframe = populate_sell_trend(dataframe) if not ticker_hist:
return dataframe 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, if dataframe.empty:
# Otherwise we might mask an error as an non-signal-scenario self.logger.warning('Empty dataframe for pair %s', pair)
def get_signal(pair: str, interval: int) -> (bool, bool): return (False, False) # return False ?
"""
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 ?
try: latest = dataframe.iloc[-1]
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 ?
if dataframe.empty: # Check if dataframe is out of date
logger.warning('Empty dataframe for pair %s', pair) signal_date = arrow.get(latest['date'])
return (False, False) # return False ? 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 def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool, sell: bool) -> bool:
signal_date = arrow.get(latest['date']) """
if signal_date < arrow.now() - timedelta(minutes=(interval + 5)): This function evaluate if on the condition required to trigger a sell has been reached
logger.warning('Too old dataframe for pair %s', pair) if the threshold is reached and updates the trade record.
return (False, False) # return False ? :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 # Experimental: Check if the trade is profitable before selling it (avoid selling at loss)
logger.debug('trigger: %s (pair=%s) buy=%s sell=%s', latest['date'], pair, str(buy), str(sell)) if self.config.get('experimental', {}).get('sell_profit_only', False):
return (buy, sell) 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

View File

@ -1,16 +1,45 @@
# pragma pylint: disable=missing-docstring, C0103 # pragma pylint: disable=missing-docstring, C0103
"""
Unit test file for analyse.py
"""
import datetime import datetime
from unittest.mock import MagicMock from unittest.mock import MagicMock
import arrow
import logging import logging
import arrow
from pandas import DataFrame from pandas import DataFrame
import freqtrade.tests.conftest as tt # test tools import freqtrade.tests.conftest as tt # test tools
from freqtrade.analyze import (get_signal, parse_ticker_dataframe, from freqtrade.analyze import Analyze, SignalType
populate_buy_trend, populate_indicators,
populate_sell_trend)
from freqtrade.strategy.strategy import Strategy # 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): def test_dataframe_correct_columns(result):
@ -18,71 +47,75 @@ def test_dataframe_correct_columns(result):
['close', 'high', 'low', 'open', 'date', 'volume'] ['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): def test_populates_buy_trend(result):
# Load the default strategy for the unit test, because this logic is done in main.py # Load the default strategy for the unit test, because this logic is done in main.py
Strategy().init({'strategy': 'default_strategy'}) dataframe = _ANALYZE.populate_buy_trend(_ANALYZE.populate_indicators(result))
dataframe = populate_buy_trend(populate_indicators(result))
assert 'buy' in dataframe.columns assert 'buy' in dataframe.columns
def test_populates_sell_trend(result): def test_populates_sell_trend(result):
# Load the default strategy for the unit test, because this logic is done in main.py # Load the default strategy for the unit test, because this logic is done in main.py
Strategy().init({'strategy': 'default_strategy'}) dataframe = _ANALYZE.populate_sell_trend(_ANALYZE.populate_indicators(result))
dataframe = populate_sell_trend(populate_indicators(result))
assert 'sell' in dataframe.columns assert 'sell' in dataframe.columns
def test_returns_latest_buy_signal(mocker): def test_returns_latest_buy_signal(mocker):
mocker.patch('freqtrade.analyze.get_ticker_history', return_value=MagicMock()) 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( mocker.patch.multiple(
'freqtrade.analyze.analyze_ticker', 'freqtrade.analyze.Analyze',
return_value=DataFrame([{'buy': 0, 'sell': 1, 'date': arrow.utcnow()}]) 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): def test_returns_latest_sell_signal(mocker):
mocker.patch('freqtrade.analyze.get_ticker_history', return_value=MagicMock()) mocker.patch('freqtrade.analyze.get_ticker_history', return_value=MagicMock())
mocker.patch( mocker.patch.multiple(
'freqtrade.analyze.analyze_ticker', 'freqtrade.analyze.Analyze',
return_value=DataFrame([{'sell': 1, 'buy': 0, 'date': arrow.utcnow()}]) analyze_ticker=MagicMock(
return_value=DataFrame([{'sell': 1, 'buy': 0, 'date': arrow.utcnow()}])
)
) )
assert get_signal('BTC-ETH', 5) == (False, True)
mocker.patch( assert _ANALYZE.get_signal('BTC-ETH', 5) == (False, True)
'freqtrade.analyze.analyze_ticker',
return_value=DataFrame([{'sell': 0, 'buy': 1, 'date': arrow.utcnow()}]) 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): def test_get_signal_empty(default_conf, mocker, caplog):
caplog.set_level(logging.INFO) caplog.set_level(logging.INFO)
mocker.patch('freqtrade.analyze.get_ticker_history', return_value=None) mocker.patch('freqtrade.analyze.get_ticker_history', return_value=None)
assert (False, False) == get_signal('foo', int(default_conf['ticker_interval'])) assert (False, False) == _ANALYZE.get_signal('foo', int(default_conf['ticker_interval']))
assert tt.log_has('Empty ticker history for pair foo', assert tt.log_has('Empty ticker history for pair foo', caplog.record_tuples)
caplog.record_tuples)
def test_get_signal_exception_valueerror(default_conf, mocker, caplog): def test_get_signal_exception_valueerror(default_conf, mocker, caplog):
caplog.set_level(logging.INFO) caplog.set_level(logging.INFO)
mocker.patch('freqtrade.analyze.get_ticker_history', return_value=1) mocker.patch('freqtrade.analyze.get_ticker_history', return_value=1)
mocker.patch('freqtrade.analyze.analyze_ticker', mocker.patch.multiple(
side_effect=ValueError('xyz')) 'freqtrade.analyze.Analyze',
assert (False, False) == get_signal('foo', int(default_conf['ticker_interval'])) 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', assert tt.log_has('Unable to analyze ticker for pair foo: xyz',
caplog.record_tuples) 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): def test_get_signal_empty_dataframe(default_conf, mocker, caplog):
caplog.set_level(logging.INFO) caplog.set_level(logging.INFO)
mocker.patch('freqtrade.analyze.get_ticker_history', return_value=1) mocker.patch('freqtrade.analyze.get_ticker_history', return_value=1)
mocker.patch('freqtrade.analyze.analyze_ticker', return_value=DataFrame([])) mocker.patch.multiple(
assert (False, False) == get_signal('xyz', int(default_conf['ticker_interval'])) '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', assert tt.log_has('Empty dataframe for pair xyz',
caplog.record_tuples) 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 # FIX: The get_signal function has hardcoded 10, which we must inturn hardcode
oldtime = arrow.utcnow() - datetime.timedelta(minutes=11) oldtime = arrow.utcnow() - datetime.timedelta(minutes=11)
ticks = DataFrame([{'buy': 1, 'date': oldtime}]) ticks = DataFrame([{'buy': 1, 'date': oldtime}])
mocker.patch('freqtrade.analyze.analyze_ticker', return_value=DataFrame(ticks)) mocker.patch.multiple(
assert (False, False) == get_signal('xyz', int(default_conf['ticker_interval'])) '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', assert tt.log_has('Too old dataframe for pair xyz',
caplog.record_tuples) caplog.record_tuples)
def test_get_signal_handles_exceptions(mocker): def test_get_signal_handles_exceptions(mocker):
mocker.patch('freqtrade.analyze.get_ticker_history', return_value=MagicMock()) mocker.patch('freqtrade.analyze.get_ticker_history', return_value=MagicMock())
mocker.patch('freqtrade.analyze.analyze_ticker', mocker.patch.multiple(
side_effect=Exception('invalid ticker history ')) '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): def test_parse_ticker_dataframe(ticker_history, ticker_history_without_bv):
columns = ['close', 'high', 'low', 'open', 'date', 'volume'] columns = ['close', 'high', 'low', 'open', 'date', 'volume']
# Test file with BV data # Test file with BV data
dataframe = parse_ticker_dataframe(ticker_history) dataframe = Analyze.parse_ticker_dataframe(ticker_history)
assert dataframe.columns.tolist() == columns assert dataframe.columns.tolist() == columns
# Test file without BV data # 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 assert dataframe.columns.tolist() == columns