From c88c3f9b84bbf1abb1792687870f241327fd3cfe Mon Sep 17 00:00:00 2001 From: xsmile <> Date: Sat, 30 Sep 2017 16:18:14 +0200 Subject: [PATCH] Backtesting: Offline runs with test data, use argument '--backtesting' --- freqtrade/analyze.py | 24 +- freqtrade/exchange/__init__.py | 16 +- freqtrade/exchange/backtesting.py | 220 +++++++++++++++++++ freqtrade/main.py | 48 ++-- freqtrade/misc.py | 29 +++ freqtrade/persistence.py | 5 +- freqtrade/tests/test_backtesting_internal.py | 206 +++++++++++++++++ 7 files changed, 521 insertions(+), 27 deletions(-) create mode 100644 freqtrade/exchange/backtesting.py create mode 100644 freqtrade/tests/test_backtesting_internal.py diff --git a/freqtrade/analyze.py b/freqtrade/analyze.py index 159e1d137..48d6b381e 100644 --- a/freqtrade/analyze.py +++ b/freqtrade/analyze.py @@ -6,7 +6,8 @@ import arrow import talib.abstract as ta from pandas import DataFrame -from freqtrade.exchange import get_ticker_history +from freqtrade.exchange import backtesting, get_ticker_history +from freqtrade.misc import State, get_state logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') @@ -23,6 +24,10 @@ def parse_ticker_dataframe(ticker: list, minimum_date: arrow.Arrow) -> DataFrame .drop('BV', 1) \ .rename(columns={'C':'close', 'V':'volume', 'O':'open', 'H':'high', 'L':'low', 'T':'date'}) \ .sort_values('date') + + if get_state() == State.BACKTESTING: + return df + return df[df['date'].map(arrow.get) > minimum_date] @@ -69,16 +74,20 @@ def analyze_ticker(pair: str) -> DataFrame: add several TA indicators and buy signal to it :return DataFrame with ticker data and indicator data """ - minimum_date = arrow.utcnow().shift(hours=-24) + if get_state() == State.BACKTESTING: + minimum_date = backtesting.get_minimum_date(pair) + else: + minimum_date = arrow.utcnow().shift(hours=-24) + data = get_ticker_history(pair, minimum_date) dataframe = parse_ticker_dataframe(data['result'], minimum_date) - if dataframe.empty: logger.warning('Empty dataframe for pair %s', pair) return dataframe dataframe = populate_indicators(dataframe) dataframe = populate_buy_trend(dataframe) + return dataframe @@ -95,10 +104,11 @@ def get_buy_signal(pair: str) -> bool: latest = dataframe.iloc[-1] - # Check if dataframe is out of date - signal_date = arrow.get(latest['date']) - if signal_date < arrow.now() - timedelta(minutes=10): - return False + if get_state() != State.BACKTESTING: + # Check if dataframe is out of date + signal_date = arrow.get(latest['date']) + if signal_date < arrow.now() - timedelta(minutes=10): + return False signal = latest['buy'] == 1 logger.debug('buy_trigger: %s (pair=%s, signal=%s)', latest['date'], pair, signal) diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index 77a2d4b84..08d9a606f 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -4,8 +4,10 @@ from typing import List import arrow +from freqtrade.exchange.backtesting import Backtesting from freqtrade.exchange.bittrex import Bittrex from freqtrade.exchange.interface import Exchange +from freqtrade.misc import State, get_state logger = logging.getLogger(__name__) @@ -33,22 +35,24 @@ def init(config: dict) -> None: _CONF.update(config) - if config['dry_run']: + if get_state() == State.BACKTESTING: + logger.info('Instance is running with backtesting enabled') + EXCHANGE = Backtesting(config['exchange']) + return + elif config['dry_run']: logger.info('Instance is running with dry_run enabled') - exchange_config = config['exchange'] - # Find matching class for the given exchange name - name = exchange_config['name'] + name = _CONF['exchange']['name'] try: exchange_class = Exchanges[name.upper()].value except KeyError: raise RuntimeError('Exchange {} is not supported'.format(name)) - EXCHANGE = exchange_class(exchange_config) + EXCHANGE = exchange_class(_CONF['exchange']) # Check if all pairs are available - validate_pairs(config['exchange']['pair_whitelist']) + validate_pairs(_CONF['exchange']['pair_whitelist']) def validate_pairs(pairs: List[str]) -> None: diff --git a/freqtrade/exchange/backtesting.py b/freqtrade/exchange/backtesting.py new file mode 100644 index 000000000..9c064184d --- /dev/null +++ b/freqtrade/exchange/backtesting.py @@ -0,0 +1,220 @@ +# pylint: disable=C0103 +import glob +import json +import logging +import os +from datetime import datetime, timedelta +from os.path import basename, splitext +from typing import List, Optional + +import arrow +from sqlalchemy import func, text + +from freqtrade import exchange +from freqtrade.exchange.interface import Exchange +from freqtrade.persistence import Trade + +logger = logging.getLogger(__name__) + +TESTDATA_DIR = os.path.join('freqtrade', 'tests', 'testdata') +# TODO: Define a global value for analyze and backtesting +TICKER_HISTORY_INTERVAL_H: int = 24 + +_ROW_INDEX: int = 0 +_ROW_INTERVAL: int = 0 +_LEN_ROWS: int = 0 +_TESTDATA: dict = {} + + +class Backtesting(Exchange): + @property + def sleep_time(self) -> float: + return 0 + + def __init__(self, config: dict, testdata_dir: Optional[str] = TESTDATA_DIR) -> None: + global _ROW_INDEX, _ROW_INTERVAL, _LEN_ROWS, _TESTDATA + + # Disable debug logs for a quicker run + # logging.disable(logging.DEBUG) + + # Get pairs from test data directory and inject into config replacing existing ones + (files, pairs) = _get_testdata_pairs(testdata_dir) + config['pair_whitelist'] = pairs + + # Load the test data for each pair + (_TESTDATA, _LEN_ROWS) = _get_testdata(files, pairs) + + # Set first row according to shift time + # to have some ticker history available for the first analysis + _ROW_INDEX = _initial_row_index(_TESTDATA[pairs[0]]['result'], TICKER_HISTORY_INTERVAL_H) + _ROW_INTERVAL = _ROW_INDEX + if _ROW_INDEX >= _LEN_ROWS or _ROW_INDEX == 0: + raise RuntimeError('Test data is not usable with the current row interval') + + def buy(self, pair: str, rate: float, amount: float) -> str: + return 'backtesting' + + def sell(self, pair: str, rate: float, amount: float) -> str: + return 'backtesting' + + def get_balance(self, currency: str) -> float: + return 999.9 + + def get_ticker(self, pair: str) -> dict: + row = _TESTDATA[pair]['result'][_ROW_INDEX] + return {'bid': row['C'], 'ask': row['C'], 'last': row['C']} + + def get_ticker_history(self, pair: str, minimum_date: Optional[arrow.Arrow] = None): + minimum = _ROW_INDEX - _ROW_INTERVAL + maximum = _ROW_INDEX + return \ + {'success': True, 'message': '', 'result': _TESTDATA[pair]['result'][minimum:maximum]} + + def cancel_order(self, order_id: str) -> None: + return + + def get_open_orders(self, pair: str) -> List[dict]: + return [] + + def get_pair_detail_url(self, pair: str) -> str: + return '' + + def get_markets(self) -> List[str]: + return [] + + +def _get_testdata_pairs(directory: str) -> (List[str], List[str]): + """ + Returns a file and a pair list for a given test data directory. + :param directory: Test data directory containing JSON files + :return: File list with paths relative to the project directory, pair list e.g. ['BTC_ETC'] + """ + files = sorted(glob.glob(os.path.join(directory, '*.json'))) + pairs = sorted([splitext(basename(p))[0].replace('-', '_').upper() for p in files]) + return files, pairs + + +def _get_testdata(files: List[str], pairs) -> (dict, int): + """ + Returns test data content from a given file list. + :param files: JSON file list with paths relative to the project directory + :param pairs: Pair list, e.g. ['BTC_ETC'] + :return: Test data dict with currency pairs as keys, maximal common testdata length for pairs + """ + testdata_lengths = [] # Find a common maximum for all testdata pairs + testdata = {} + for (file, pair) in zip(files, pairs): + with open(file) as f: + testdata[pair] = json.load(f) + testdata_lengths.append(len(testdata[pair]['result'])) + len_rows = min(testdata_lengths) + return testdata, len_rows + + +def _initial_row_index(ticker_history: dict, ticker_history_interval: float) -> int: + """ + Calculates the initial row index to have a buffer for the first signal analysis. + :param ticker_history: Ticker history of a currency pair + :param ticker_history_interval: Time interval used during the ticker analysis + :return: Calculated initial row index + """ + result = 0 + t0 = arrow.get(ticker_history[0]['T']) + t1 = t0.shift(hours=ticker_history_interval) + for i, row in enumerate(ticker_history): + row_time = arrow.get(row['T']) + if row_time >= t1: + result = i + break + return result + + +def time_step() -> bool: + """ + Advances in time or rather increases the row counter by 1 (one). + :return: Success status - False if the end of a data set is reached + """ + global _ROW_INDEX + + time = _ROW_INDEX + _ROW_INDEX += 1 + logger.debug('Row: %s/%s', time, _LEN_ROWS) + if time >= _LEN_ROWS: # Backtesting complete + return False + return True + + +def get_minimum_date(pair: str) -> datetime: + """ + Subtracts the ticker history interval (e.g. 24 hours) from the current time. + :param pair: Pair as str, format: BTC_ETH + :return: datetime + """ + ticker_history = _TESTDATA[pair]['result'] + minimum_row = _ROW_INDEX - _ROW_INTERVAL + minimum_date = ticker_history[minimum_row]['T'] + return minimum_date + + +def current_time(pair: str) -> datetime: + """ + Gets the time stored in the current row of a data set for a given currency pair. + :param pair: Pair as str, format: BTC_ETC + :return: datetime + """ + return arrow.get(_TESTDATA[pair]['result'][_ROW_INDEX]['T']).datetime.replace(tzinfo=None) + + +# TODO: Common statistic methods for telegram and backtesting +def print_results() -> None: + """ + Prints cumulative profit statistics. + :return: None + """ + trades = Trade.query.order_by(Trade.id).all() + + profit_amounts = [] + profits = [] + durations = [] + for trade in trades: + if trade.close_date: + durations.append((trade.close_date - trade.open_date).total_seconds()) + if trade.close_profit: + profit = trade.close_profit + else: + # Get current rate + current_rate = exchange.get_ticker(trade.pair)['bid'] + profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate) + + profit_amounts.append((profit / 100) * trade.stake_amount) + profits.append(profit) + + best_pair = Trade.session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \ + .filter(Trade.is_open.is_(False)) \ + .group_by(Trade.pair) \ + .order_by(text('profit_sum DESC')) \ + .first() + + if not best_pair: + logger.info('No closed trade') + return + + bp_pair, bp_rate = best_pair + msg = """ +ROI: {profit_btc:.2f} ({profit:.2f}%) +Trade Count: {trade_count} +First Trade opened: {first_trade_date} +Latest Trade opened: {latest_trade_date} +Avg. Duration: {avg_duration} +Best Performing: {best_pair}: {best_rate:.2f}% + """.format( + profit_btc=round(sum(profit_amounts), 8), + profit=round(sum(profits), 2), + trade_count=len(trades), + first_trade_date=arrow.get(trades[0].open_date).humanize(), + latest_trade_date=arrow.get(trades[-1].open_date).humanize(), + avg_duration=str(timedelta(seconds=sum(durations) / float(len(durations)))).split('.')[0], + best_pair=bp_pair, + best_rate=round(bp_rate, 2), + ) + logger.info(msg) diff --git a/freqtrade/main.py b/freqtrade/main.py index ce933fe44..fc2fb11b3 100755 --- a/freqtrade/main.py +++ b/freqtrade/main.py @@ -2,6 +2,7 @@ import copy import json import logging +import sys import time import traceback from datetime import datetime @@ -11,7 +12,8 @@ from jsonschema import validate from freqtrade import __version__, exchange, persistence from freqtrade.analyze import get_buy_signal -from freqtrade.misc import CONF_SCHEMA, State, get_state, update_state +from freqtrade.exchange import backtesting +from freqtrade.misc import CONF_SCHEMA, State, get_args, get_state, parse_args, update_state from freqtrade.persistence import Trade from freqtrade.rpc import telegram @@ -106,6 +108,9 @@ def should_sell(trade: Trade, current_rate: float, current_time: datetime) -> bo Based an earlier trade and current price and configuration, decides whether bot should sell :return True if bot should sell at current rate """ + if get_state() == State.BACKTESTING: + current_time = backtesting.current_time(trade.pair) + current_profit = (current_rate - trade.open_rate) / trade.open_rate if 'stoploss' in _CONF and current_profit < float(_CONF['stoploss']): @@ -186,6 +191,10 @@ def create_trade(stake_amount: float) -> Optional[Trade]: order_id = exchange.buy(pair, open_rate, amount) # Create trade entity and return + if get_state() == State.BACKTESTING: + current_time = backtesting.current_time(pair) + else: + current_time = datetime.utcnow() message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format( exchange.EXCHANGE.name.upper(), pair.replace('_', '/'), @@ -197,41 +206,48 @@ def create_trade(stake_amount: float) -> Optional[Trade]: return Trade(pair=pair, stake_amount=stake_amount, open_rate=open_rate, - open_date=datetime.utcnow(), + open_date=current_time, amount=amount, exchange=exchange.EXCHANGE.name.upper(), open_order_id=order_id, is_open=True) -def init(config: dict, db_url: Optional[str] = None) -> None: +def init(config: dict, db_url: Optional[str] = None, argv: Optional[list] = None) -> None: """ Initializes all modules and updates the config :param config: config as dict :param db_url: database connector string for sqlalchemy (Optional) + :param argv: (optional) command-line arguments :return: None """ + # Parse command-line arguments + parse_args(argv) + + # Set initial application state + if get_args().backtesting: + initial_state = State.BACKTESTING + _CONF['telegram']['enabled'] = False + else: + initial_state = config.get('initial_state', State.STOPPED.name.lower()) + initial_state = State[initial_state.upper()] + update_state(initial_state) + # Initialize all modules telegram.init(config) persistence.init(config, db_url) exchange.init(config) - # Set initial application state - initial_state = config.get('initial_state') - if initial_state: - update_state(State[initial_state.upper()]) - else: - update_state(State.STOPPED) - -def app(config: dict) -> None: +def app(config: dict, argv: Optional[list] = None) -> None: """ Main loop which handles the application state :param config: config as dict + :param argv: (optional) command-line arguments :return: None """ logger.info('Starting freqtrade %s', __version__) - init(config) + init(config, argv=argv) try: old_state = get_state() logger.info('Initial State: %s', old_state) @@ -249,6 +265,12 @@ def app(config: dict) -> None: _process() # We need to sleep here because otherwise we would run into bittrex rate limit time.sleep(exchange.EXCHANGE.sleep_time) + elif new_state == State.BACKTESTING: + _process() + # Advance in time without sleeping + if not backtesting.time_step(): + backtesting.print_results() + break old_state = new_state except RuntimeError: telegram.send_msg('*Status:* Got RuntimeError: ```\n{}\n```'.format(traceback.format_exc())) @@ -266,7 +288,7 @@ def main(): with open('config.json') as file: _CONF = json.load(file) validate(_CONF, CONF_SCHEMA) - app(_CONF) + app(_CONF, sys.argv) if __name__ == '__main__': diff --git a/freqtrade/misc.py b/freqtrade/misc.py index 585aee3de..d065e0749 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -1,4 +1,6 @@ +import argparse import enum +from typing import Optional from wrapt import synchronized @@ -6,8 +8,12 @@ from wrapt import synchronized class State(enum.Enum): RUNNING = 0 STOPPED = 1 + BACKTESTING = 2 +# Command-line arguments +_ARGS: argparse.Namespace = None + # Current application state _STATE = State.STOPPED @@ -32,6 +38,29 @@ def get_state() -> State: return _STATE +def parse_args(argv: Optional[list] = None) -> None: + """ + Parses and stores command-line arguments. + :param argv: (optional) command-line arguments + :return: None + """ + global _ARGS + + parser = argparse.ArgumentParser( + description='Simple High Frequency Trading Bot for crypto currencies') + parser.add_argument('-b', '--backtesting', action='store_true', + help='test bot performance on offline data and print results') + _ARGS = parser.parse_args(argv[1:] if argv else []) + + +def get_args(): + """ + Gets the current command-line arguments. + :return: arguments + """ + return _ARGS + + # Required json-schema for user specified config CONF_SCHEMA = { 'type': 'object', diff --git a/freqtrade/persistence.py b/freqtrade/persistence.py index 7f8bfbc69..7ea1680f2 100644 --- a/freqtrade/persistence.py +++ b/freqtrade/persistence.py @@ -8,6 +8,7 @@ from sqlalchemy.orm.session import sessionmaker from sqlalchemy.types import Enum from freqtrade import exchange +from freqtrade.misc import State, get_state _CONF = {} @@ -25,7 +26,9 @@ def init(config: dict, db_url: Optional[str] = None) -> None: """ _CONF.update(config) if not db_url: - if _CONF.get('dry_run', False): + if get_state() == State.BACKTESTING: + db_url = 'sqlite://' + elif _CONF.get('dry_run', False): db_url = 'sqlite:///tradesv2.dry_run.sqlite' else: db_url = 'sqlite:///tradesv2.sqlite' diff --git a/freqtrade/tests/test_backtesting_internal.py b/freqtrade/tests/test_backtesting_internal.py new file mode 100644 index 000000000..35f90120a --- /dev/null +++ b/freqtrade/tests/test_backtesting_internal.py @@ -0,0 +1,206 @@ +# pragma pylint: disable=missing-docstring,protected-access +import os +from datetime import datetime +from unittest.mock import MagicMock + +import arrow +import pytest +from jsonschema import validate + +from freqtrade.exchange import backtesting +from freqtrade.exchange.backtesting import Backtesting +from freqtrade.main import create_trade, init +from freqtrade.misc import CONF_SCHEMA +from freqtrade.persistence import Trade + + +@pytest.fixture +def conf(): + configuration = { + 'max_open_trades': 3, + 'stake_currency': 'BTC', + 'stake_amount': 0.05, + 'dry_run': True, + 'minimal_roi': { + '60': 0.0, + '40': 0.01, + '20': 0.02, + '0': 0.03 + }, + 'stoploss': -0.40, + 'bid_strategy': { + 'ask_last_balance': 0.0 + }, + 'exchange': { + 'name': 'bittrex', + 'key': 'key', + 'secret': 'secret', + 'pair_whitelist': [ + 'BTC_RLC' + ] + }, + 'telegram': { + 'enabled': False, + 'token': 'token', + 'chat_id': 'chat_id' + } + } + validate(configuration, CONF_SCHEMA) + return configuration + + +FILES = [ + os.path.join('freqtrade', 'tests', 'testdata', 'btc-edg.json'), + os.path.join('freqtrade', 'tests', 'testdata', 'btc-etc.json') +] +PAIRS = ['BTC_EDG', 'BTC_ETC'] +TESTDATA = { + PAIRS[0]: { + 'success': True, + 'message': '', + 'result': [ + {'O': 0.00014469, 'H': 0.00014469, 'L': 0.00014469, 'C': 0.00014469, 'V': 10.66173857, 'T': '2017-09-05T18:55:00', 'BV': 0.00154264}, + {'O': 0.00014469, 'H': 0.00014477, 'L': 0.00014469, 'C': 0.00014477, 'V': 410.54795113, 'T': '2017-09-05T19:00:00', 'BV': 0.05942728}, + {'O': 0.00014477, 'H': 0.00014477, 'L': 0.00014477, 'C': 0.00014477, 'V': 69.10850034, 'T': '2017-09-05T19:05:00', 'BV': 0.01000482}, + {'O': 0.00014470, 'H': 0.00014474, 'L': 0.00014400, 'C': 0.00014473, 'V': 7612.36224582, 'T': '2017-09-05T19:10:00', 'BV': 1.09730748}, + ] + }, + PAIRS[1]: { + 'success': True, + 'message': '', + 'result': [ + {'O': 0.00391500, 'H': 0.00392700, 'L': 0.00391500, 'C': 0.00392000, 'V': 29.90264260, 'T': '2017-09-05T18:55:00', 'BV': 0.11712504}, + {'O': 0.00392680, 'H': 0.00392749, 'L': 0.00391500, 'C': 0.00391500, 'V': 329.35043009, 'T': '2017-09-05T19:00:00', 'BV': 1.29065913}, + {'O': 0.00391500, 'H': 0.00392733, 'L': 0.00391500, 'C': 0.00392300, 'V': 186.96019741, 'T': '2017-09-05T19:05:00', 'BV': 0.73332203}, + {'O': 0.00391500, 'H': 0.00391500, 'L': 0.00390007, 'C': 0.00390007, 'V': 298.06457786, 'T': '2017-09-05T19:10:00', 'BV': 1.16560055}, + {'O': 0.00391490, 'H': 0.00391490, 'L': 0.00389126, 'C': 0.00389126, 'V': 1007.91208513, 'T': '2017-09-05T19:15:00', 'BV': 3.92491826} + ] + } +} + + +@pytest.fixture() +def len_rows(): + ticker_history = TESTDATA[PAIRS[0]] + return len(ticker_history['result']) + + +def _json_load(file): + pair = os.path.splitext(os.path.basename(file.name))[0].replace('-', '_').upper() + return TESTDATA[pair] + + +def test_init(conf, mocker): + mocker.patch('json.load', side_effect=_json_load) + mocker.patch('glob.glob', return_value=FILES) + mocker.patch.dict('freqtrade.main._CONF', conf) + mocker.patch('freqtrade.main.get_args', backtesting=True) + mocker.patch('freqtrade.exchange', init=MagicMock()) + mocker.patch('freqtrade.main.backtesting.TICKER_HISTORY_INTERVAL_H', 1/6) + init(conf) + assert backtesting._TESTDATA == TESTDATA + assert backtesting._LEN_ROWS == 4 + # 10 minutes (1/6 hours) should correspond to 2 rows + assert backtesting._ROW_INDEX == 2 + assert backtesting._ROW_INTERVAL == 2 + + # Available test data should not cover the interval + mocker.patch('freqtrade.main.backtesting.TICKER_HISTORY_INTERVAL_H', 24) + with pytest.raises(RuntimeError): + assert init(conf) + + +def test_time_step(len_rows, mocker): + mocker.patch('freqtrade.exchange.backtesting._LEN_ROWS', len_rows) + for index in range(4): + assert backtesting._ROW_INDEX == index + assert backtesting.time_step() is True + assert backtesting.time_step() is False + + +def test_returns_minimum_date(mocker): + mocker.patch('freqtrade.exchange.backtesting._TESTDATA', TESTDATA) + mocker.patch('freqtrade.exchange.backtesting._ROW_INDEX', 3) + mocker.patch('freqtrade.exchange.backtesting._ROW_INTERVAL', 2) + assert backtesting.get_minimum_date(PAIRS[0]) == '2017-09-05T19:00:00' + + +def test_returns_ticker(conf, mocker): + mocker.patch('freqtrade.main.get_args', backtesting=True) + mocker.patch('freqtrade.exchange.backtesting._get_testdata', return_value=(TESTDATA, 4)) + mocker.patch('freqtrade.exchange.backtesting.TICKER_HISTORY_INTERVAL_H', 1/6) + first_pair_close = 0.00014477 + backtesting = Backtesting(conf) + mocker.patch('freqtrade.exchange.backtesting._ROW_INDEX', 2) + assert backtesting.get_ticker(PAIRS[0]) == \ + {'bid': first_pair_close, 'ask': first_pair_close, 'last': first_pair_close} + + +def test_returns_ticker_history(conf, mocker): + mocker.patch('freqtrade.exchange.backtesting._get_testdata', return_value=(TESTDATA, 4)) + mocker.patch('freqtrade.main.backtesting.TICKER_HISTORY_INTERVAL_H', 1/6) + backtesting = Backtesting(conf) + mocker.patch('freqtrade.exchange.backtesting._ROW_INDEX', 3) + mocker.patch('freqtrade.exchange.backtesting._ROW_INTERVAL', 2) + assert backtesting.get_ticker_history(PAIRS[0]) == \ + {'success': True, + 'message': '', + 'result': [ + {'O': 0.00014469, 'H': 0.00014477, 'L': 0.00014469, 'C': 0.00014477, 'V': 410.54795113, 'T': '2017-09-05T19:00:00', 'BV': 0.05942728}, + {'O': 0.00014477, 'H': 0.00014477, 'L': 0.00014477, 'C': 0.00014477, 'V': 69.10850034, 'T': '2017-09-05T19:05:00', 'BV': 0.01000482} + ]} + + +def test_returns_current_time(mocker): + mocker.patch('freqtrade.exchange.backtesting._TESTDATA', TESTDATA) + mocker.patch('freqtrade.exchange.backtesting._ROW_INDEX', 1) + assert backtesting.current_time(PAIRS[0]) == \ + arrow.get('2017-09-05T19:00:00').datetime.replace(tzinfo=None) + + +def test_results(conf, mocker): + current_time = datetime.utcnow() + logger_mock = MagicMock() + mocker.patch.dict('freqtrade.main._CONF', conf) + mocker.patch('freqtrade.main.get_args', backtesting=True) + mocker.patch('freqtrade.main.backtesting', init=MagicMock()) + mocker.patch('freqtrade.main.telegram', init=MagicMock()) + mocker.patch('freqtrade.main.backtesting.current_time', return_value=current_time) + mocker.patch('freqtrade.main.get_buy_signal', return_value=True) + mocker.patch('freqtrade.exchange.backtesting.logger', info=logger_mock) + mocker.patch.multiple('freqtrade.main.exchange', + get_ticker=MagicMock(return_value={ + 'bid': 0.07256061, + 'ask': 0.072661, + 'last': 0.07256061 + }), + buy=MagicMock(return_value='mocked_order_id')) + init(conf) + + # Create some test data + trade = create_trade(15.0) + assert trade + trade.close_rate = 0.07256061 + trade.close_profit = 100.00 + trade.close_date = current_time + trade.open_order_id = None + trade.is_open = False + Trade.session.add(trade) + Trade.session.flush() + + backtesting.print_results() + assert logger_mock.call_count == 1 + assert '(100.00%)' in logger_mock.call_args_list[-1][0][0] + + # Trade should not be closed yet + Trade.session.delete(trade) + trade.close_rate = None + trade.close_profit = None + trade.close_date = None + trade.is_open = True + Trade.session.add(trade) + Trade.session.flush() + + backtesting.print_results() + assert logger_mock.call_count == 2 + assert 'No closed trade' in logger_mock.call_args_list[-1][0][0]