diff --git a/freqtrade/analyze.py b/freqtrade/analyze.py index 9b7a3b080..db32d6fa5 100644 --- a/freqtrade/analyze.py +++ b/freqtrade/analyze.py @@ -172,19 +172,17 @@ class Analyze(object): :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): + if self.strategy.stoploss is not None and current_profit < 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.timestamp() - trade.open_date.timestamp()) / 60 - for duration_string, threshold in self.strategy.minimal_roi.items(): - duration = float(duration_string) - if time_diff > duration and current_profit > threshold: - return True - - if time_diff < duration: + for duration, threshold in self.strategy.minimal_roi.items(): + if time_diff <= duration: return False + if current_profit > threshold: + return True return False diff --git a/freqtrade/configuration.py b/freqtrade/configuration.py index dfce90bf6..0beee789f 100644 --- a/freqtrade/configuration.py +++ b/freqtrade/configuration.py @@ -116,7 +116,7 @@ class Configuration(object): if 'realistic_simulation' in self.args and self.args.realistic_simulation: config.update({'realistic_simulation': True}) self.logger.info('Parameter --realistic-simulation detected ...') - self.logger.info('Using max_open_trades: %s ...', config.get('max_open_trades')) + self.logger.info('Using max_open_trades: %s ...', config.get('max_open_trades')) # If --timerange is used we add it to the configuration if 'timerange' in self.args and self.args.timerange: diff --git a/freqtrade/misc.py b/freqtrade/misc.py index 741a306a1..814e0fb6f 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -67,5 +67,5 @@ def file_dump_json(filename, data) -> None: :param data: JSON Data to save :return: """ - with open(filename, 'w') as file: - json.dump(data, file) + with open(filename, 'w') as fp: + json.dump(data, fp, default=str) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 51cc7c053..902263f66 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -102,43 +102,35 @@ class Backtesting(object): ]) return tabulate(tabular_data, headers=headers, floatfmt=floatfmt) - def _get_sell_trade_entry(self, pair, row, buy_subset, ticker, trade_count_lock, args): + def _get_sell_trade_entry(self, pair, buy_row, partial_ticker, trade_count_lock, args): stake_amount = args['stake_amount'] max_open_trades = args.get('max_open_trades', 0) trade = Trade( - open_rate=row.close, - open_date=row.Index, + open_rate=buy_row.close, + open_date=buy_row.date, stake_amount=stake_amount, - amount=stake_amount / row.open, + amount=stake_amount / buy_row.open, fee=exchange.get_fee() ) # calculate win/lose forwards from buy point - sell_subset = ticker[ticker.index > row.Index][['close', 'sell', 'buy']] - for row2 in sell_subset.itertuples(index=True): + for sell_row in partial_ticker: if max_open_trades > 0: # Increase trade_count_lock for every iteration - trade_count_lock[row2.Index] = trade_count_lock.get(row2.Index, 0) + 1 + trade_count_lock[sell_row.date] = trade_count_lock.get(sell_row.date, 0) + 1 - buy_signal = row2.buy - if( - self.analyze.should_sell( - trade=trade, - rate=row2.close, - date=row2.Index, - buy=buy_signal, - sell=row2.sell - ) - ): + buy_signal = sell_row.buy + if self.analyze.should_sell(trade, sell_row.close, sell_row.date, buy_signal, + sell_row.sell): return \ - row2, \ + sell_row, \ ( pair, - trade.calc_profit_percent(rate=row2.close), - trade.calc_profit(rate=row2.close), - (row2.Index - row.Index).seconds // 60 - ),\ - row2.Index + trade.calc_profit_percent(rate=sell_row.close), + trade.calc_profit(rate=sell_row.close), + (sell_row.date - buy_row.date).seconds // 60 + ), \ + sell_row.date return None def backtest(self, args) -> DataFrame: @@ -159,6 +151,7 @@ class Backtesting(object): stoploss: use stoploss :return: DataFrame """ + headers = ['date', 'buy', 'open', 'close', 'sell'] processed = args['processed'] max_open_trades = args.get('max_open_trades', 0) realistic = args.get('realistic', True) @@ -167,37 +160,28 @@ class Backtesting(object): trades = [] trade_count_lock = {} for pair, pair_data in processed.items(): - pair_data['buy'], pair_data['sell'] = 0, 0 - ticker = self.populate_sell_trend( - self.populate_buy_trend(pair_data) - ) - if 'date' in ticker: - ticker.set_index('date', inplace=True) - # for each buy point + pair_data['buy'], pair_data['sell'] = 0, 0 # cleanup from previous run + + ticker_data = self.populate_sell_trend(self.populate_buy_trend(pair_data))[headers] + ticker = [x for x in ticker_data.itertuples()] + lock_pair_until = None - headers = ['buy', 'open', 'close', 'sell'] - buy_subset = ticker[(ticker.buy == 1) & (ticker.sell == 0)][headers] - for row in buy_subset.itertuples(index=True): + for index, row in enumerate(ticker): + if row.buy == 0 or row.sell == 1: + continue # skip rows where no buy signal or that would immediately sell off + if realistic: - if lock_pair_until is not None and row.Index <= lock_pair_until: + if lock_pair_until is not None and row.date <= lock_pair_until: continue if max_open_trades > 0: # Check if max_open_trades has already been reached for the given date - if not trade_count_lock.get(row.Index, 0) < max_open_trades: + if not trade_count_lock.get(row.date, 0) < max_open_trades: continue - if max_open_trades > 0: - # Increase lock - trade_count_lock[row.Index] = trade_count_lock.get(row.Index, 0) + 1 + trade_count_lock[row.date] = trade_count_lock.get(row.date, 0) + 1 - ret = self._get_sell_trade_entry( - pair=pair, - row=row, - buy_subset=buy_subset, - ticker=ticker, - trade_count_lock=trade_count_lock, - args=args - ) + ret = self._get_sell_trade_entry(pair, row, ticker[index + 1:], + trade_count_lock, args) if ret: row2, trade_entry, next_date = ret @@ -208,9 +192,9 @@ class Backtesting(object): # record a tuple of pair, current_profit_percent, # entry-date, duration records.append((pair, trade_entry[1], - row.Index.strftime('%s'), - row2.Index.strftime('%s'), - row.Index, trade_entry[3])) + row.date.strftime('%s'), + row2.date.strftime('%s'), + row.date, trade_entry[3])) # For now export inside backtest(), maybe change so that backtest() # returns a tuple like: (dataframe, records, logs, etc) if record and record.find('trades') >= 0: @@ -226,6 +210,8 @@ class Backtesting(object): """ data = {} pairs = self.config['exchange']['pair_whitelist'] + self.logger.info('Using stake_currency: %s ...', self.config['stake_currency']) + self.logger.info('Using stake_amount: %s ...', self.config['stake_amount']) if self.config.get('live'): self.logger.info('Downloading data for all pairs in whitelist ...') @@ -233,8 +219,6 @@ class Backtesting(object): data[pair] = exchange.get_ticker_history(pair, self.ticker_interval) else: self.logger.info('Using local backtesting data (using whitelist in given config) ...') - self.logger.info('Using stake_currency: %s ...', self.config['stake_currency']) - self.logger.info('Using stake_amount: %s ...', self.config['stake_amount']) timerange = Arguments.parse_timerange(self.config.get('timerange')) data = optimize.load_data( diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index ad8ff32be..cca80fbfd 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -240,15 +240,15 @@ class Hyperopt(Backtesting): return trade_loss + profit_loss + duration_loss @staticmethod - def generate_roi_table(params) -> Dict[str, float]: + def generate_roi_table(params) -> Dict[int, float]: """ Generate the ROI table thqt will be used by Hyperopt """ roi_table = {} - roi_table["0"] = params['roi_p1'] + params['roi_p2'] + params['roi_p3'] - roi_table[str(params['roi_t3'])] = params['roi_p1'] + params['roi_p2'] - roi_table[str(params['roi_t3'] + params['roi_t2'])] = params['roi_p1'] - roi_table[str(params['roi_t3'] + params['roi_t2'] + params['roi_t1'])] = 0 + roi_table[0] = params['roi_p1'] + params['roi_p2'] + params['roi_p3'] + roi_table[params['roi_t3']] = params['roi_p1'] + params['roi_p2'] + roi_table[params['roi_t3'] + params['roi_t2']] = params['roi_p1'] + roi_table[params['roi_t3'] + params['roi_t2'] + params['roi_t1']] = 0 return roi_table diff --git a/freqtrade/strategy/strategy.py b/freqtrade/strategy/strategy.py index 3d077a7e0..607b6223f 100644 --- a/freqtrade/strategy/strategy.py +++ b/freqtrade/strategy/strategy.py @@ -58,11 +58,11 @@ class Strategy(object): # Minimal ROI designed for the strategy self.minimal_roi = OrderedDict(sorted( - self.custom_strategy.minimal_roi.items(), - key=lambda tuple: float(tuple[0]))) # sort after converting to number + {int(key): value for (key, value) in self.custom_strategy.minimal_roi.items()}.items(), + key=lambda tuple: tuple[0])) # sort after converting to number # Optimal stoploss designed for the strategy - self.stoploss = self.custom_strategy.stoploss + self.stoploss = float(self.custom_strategy.stoploss) self.ticker_interval = self.custom_strategy.ticker_interval diff --git a/freqtrade/tests/conftest.py b/freqtrade/tests/conftest.py index 3ca953156..60c4f99ed 100644 --- a/freqtrade/tests/conftest.py +++ b/freqtrade/tests/conftest.py @@ -285,6 +285,7 @@ def ticker_history_without_bv(): ] +# FIX: Perhaps change result fixture to use BTC_UNITEST instead? @pytest.fixture def result(): with open('freqtrade/tests/testdata/BTC_ETH-1.json') as data_file: diff --git a/freqtrade/tests/optimize/test_backtesting.py b/freqtrade/tests/optimize/test_backtesting.py index 06133be82..728a77e53 100644 --- a/freqtrade/tests/optimize/test_backtesting.py +++ b/freqtrade/tests/optimize/test_backtesting.py @@ -1,12 +1,14 @@ # pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, unused-argument import json +import random import math from typing import List from copy import deepcopy from unittest.mock import MagicMock from arrow import Arrow import pandas as pd +import numpy as np from freqtrade import optimize from freqtrade.optimize.backtesting import Backtesting, start, setup_configuration from freqtrade.arguments import Arguments @@ -96,6 +98,70 @@ def mocked_load_data(datadir, pairs=[], ticker_interval=0, refresh_pairs=False, return pairdata +# use for mock freqtrade.exchange.get_ticker_history' +def _load_pair_as_ticks(pair, tickfreq): + ticks = optimize.load_data(None, ticker_interval=tickfreq, pairs=[pair]) + ticks = trim_dictlist(ticks, -200) + return ticks[pair] + + +# FIX: fixturize this? +def _make_backtest_conf(conf=None, pair='BTC_UNITEST', record=None): + data = optimize.load_data(None, ticker_interval=8, pairs=[pair]) + data = trim_dictlist(data, -200) + return { + 'stake_amount': conf['stake_amount'], + 'processed': _BACKTESTING.tickerdata_to_dataframe(data), + 'max_open_trades': 10, + 'realistic': True, + 'record': record + } + + +def _trend(signals, buy_value, sell_value): + n = len(signals['low']) + buy = np.zeros(n) + sell = np.zeros(n) + for i in range(0, len(signals['buy'])): + if random.random() > 0.5: # Both buy and sell signals at same timeframe + buy[i] = buy_value + sell[i] = sell_value + signals['buy'] = buy + signals['sell'] = sell + return signals + + +def _trend_alternate(dataframe=None): + signals = dataframe + low = signals['low'] + n = len(low) + buy = np.zeros(n) + sell = np.zeros(n) + for i in range(0, len(buy)): + if i % 2 == 0: + buy[i] = 1 + else: + sell[i] = 1 + signals['buy'] = buy + signals['sell'] = sell + return dataframe + + +def _run_backtest_1(fun, backtest_conf): + # strategy is a global (hidden as a singleton), so we + # emulate strategy being pure, by override/restore here + # if we dont do this, the override in strategy will carry over + # to other tests + old_buy = _BACKTESTING.populate_buy_trend + old_sell = _BACKTESTING.populate_sell_trend + _BACKTESTING.populate_buy_trend = fun # Override + _BACKTESTING.populate_sell_trend = fun # Override + results = _BACKTESTING.backtest(backtest_conf) + _BACKTESTING.populate_buy_trend = old_buy # restore override + _BACKTESTING.populate_sell_trend = old_sell # restore override + return results + + # Unit tests def test_setup_configuration_without_arguments(mocker, default_conf, caplog) -> None: """ @@ -418,3 +484,125 @@ def test_backtest_pricecontours(default_conf) -> None: tests = [['raise', 17], ['lower', 0], ['sine', 17]] for [contour, numres] in tests: simple_backtest(default_conf, contour, numres) + + +# Test backtest using offline data (testdata directory) +def test_backtest_ticks(default_conf): + ticks = [1, 5] + fun = _BACKTESTING.populate_buy_trend + for tick in ticks: + backtest_conf = _make_backtest_conf(conf=default_conf) + results = _run_backtest_1(fun, backtest_conf) + assert not results.empty + + +def test_backtest_clash_buy_sell(default_conf): + # Override the default buy trend function in our default_strategy + def fun(dataframe=None): + buy_value = 1 + sell_value = 1 + return _trend(dataframe, buy_value, sell_value) + + backtest_conf = _make_backtest_conf(conf=default_conf) + results = _run_backtest_1(fun, backtest_conf) + assert results.empty + + +def test_backtest_only_sell(default_conf): + # Override the default buy trend function in our default_strategy + def fun(dataframe=None): + buy_value = 0 + sell_value = 1 + return _trend(dataframe, buy_value, sell_value) + + backtest_conf = _make_backtest_conf(conf=default_conf) + results = _run_backtest_1(fun, backtest_conf) + assert results.empty + + +def test_backtest_alternate_buy_sell(default_conf): + backtest_conf = _make_backtest_conf(conf=default_conf, pair='BTC_UNITEST') + results = _run_backtest_1(_trend_alternate, backtest_conf) + assert len(results) == 3 + + +def test_backtest_record(default_conf, mocker): + names = [] + records = [] + mocker.patch( + 'freqtrade.optimize.backtesting.file_dump_json', + new=lambda n, r: (names.append(n), records.append(r)) + ) + backtest_conf = _make_backtest_conf( + conf=default_conf, + pair='BTC_UNITEST', + record="trades" + ) + results = _run_backtest_1(_trend_alternate, backtest_conf) + assert len(results) == 3 + # Assert file_dump_json was only called once + assert names == ['backtest-result.json'] + records = records[0] + # Ensure records are of correct type + assert len(records) == 3 + # ('BTC_UNITEST', 0.00331158, '1510684320', '1510691700', 0, 117) + # Below follows just a typecheck of the schema/type of trade-records + oix = None + for (pair, profit, date_buy, date_sell, buy_index, dur) in records: + assert pair == 'BTC_UNITEST' + isinstance(profit, float) + # FIX: buy/sell should be converted to ints + isinstance(date_buy, str) + isinstance(date_sell, str) + isinstance(buy_index, pd._libs.tslib.Timestamp) + if oix: + assert buy_index > oix + oix = buy_index + assert dur > 0 + + +def test_backtest_start_live(default_conf, mocker, caplog): + default_conf['exchange']['pair_whitelist'] = ['BTC_UNITEST'] + mocker.patch('freqtrade.exchange.get_ticker_history', + new=lambda n, i: _load_pair_as_ticks(n, i)) + mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', MagicMock()) + mocker.patch('freqtrade.optimize.backtesting.Backtesting._generate_text_table', MagicMock()) + mocker.patch('freqtrade.configuration.open', mocker.mock_open( + read_data=json.dumps(default_conf) + )) + + args = MagicMock() + args.ticker_interval = 1 + args.level = 10 + args.live = True + args.datadir = None + args.export = None + args.strategy = 'default_strategy' + args.timerange = '-100' # needed due to MagicMock malleability + + args = [ + '--config', 'config.json', + '--strategy', 'default_strategy', + 'backtesting', + '--ticker-interval', '1', + '--live', + '--timerange', '-100' + ] + args = get_args(args) + start(args) + # check the logs, that will contain the backtest result + exists = [ + 'Parameter -i/--ticker-interval detected ...', + 'Using ticker_interval: 1 ...', + 'Parameter -l/--live detected ...', + 'Using max_open_trades: 1 ...', + 'Parameter --timerange detected: -100 ..', + 'Parameter --datadir detected: freqtrade/tests/testdata ...', + 'Using stake_currency: BTC ...', + 'Using stake_amount: 0.001 ...', + 'Downloading data for all pairs in whitelist ...', + 'Measuring data from 2017-11-14T19:32:00+00:00 up to 2017-11-14T22:59:00+00:00 (0 days)..' + ] + + for line in exists: + tt.log_has(line, caplog.record_tuples) diff --git a/freqtrade/tests/optimize/test_hyperopt.py b/freqtrade/tests/optimize/test_hyperopt.py index 30b5e4382..6d0bf2cc7 100644 --- a/freqtrade/tests/optimize/test_hyperopt.py +++ b/freqtrade/tests/optimize/test_hyperopt.py @@ -2,6 +2,7 @@ import os from copy import deepcopy from unittest.mock import MagicMock +import pandas as pd from freqtrade.optimize.hyperopt import Hyperopt import freqtrade.tests.conftest as tt # test tools @@ -157,7 +158,7 @@ def test_fmin_best_results(mocker, default_conf, caplog) -> None: '"uptrend_long_ema": {\n "enabled": true\n },', '"uptrend_short_ema": {\n "enabled": false\n },', '"uptrend_sma": {\n "enabled": false\n }', - 'ROI table:\n{\'0\': 6.0, \'3.0\': 3.0, \'5.0\': 1.0, \'6.0\': 0}', + 'ROI table:\n{0: 6.0, 3.0: 3.0, 5.0: 1.0, 6.0: 0}', 'Best Result:\nfoo' ] for line in exists: @@ -275,7 +276,7 @@ def test_roi_table_generation() -> None: } hyperopt = _HYPEROPT - assert hyperopt.generate_roi_table(params) == {'0': 6, '15': 3, '25': 1, '30': 0} + assert hyperopt.generate_roi_table(params) == {0: 6, 15: 3, 25: 1, 30: 0} def test_start_calls_fmin(mocker, default_conf) -> None: @@ -319,3 +320,36 @@ def test_start_uses_mongotrials(mocker, default_conf) -> None: hyperopt.start() mock_mongotrials.assert_called_once() mock_fmin.assert_called_once() + + +# test log_trials_result +# test buy_strategy_generator def populate_buy_trend +# test optimizer if 'ro_t1' in params + +def test_format_results(): + """ + Test Hyperopt.format_results() + """ + trades = [ + ('BTC_ETH', 2, 2, 123), + ('BTC_LTC', 1, 1, 123), + ('BTC_XRP', -1, -2, -246) + ] + labels = ['currency', 'profit_percent', 'profit_BTC', 'duration'] + df = pd.DataFrame.from_records(trades, columns=labels) + x = Hyperopt.format_results(df) + assert x.find(' 66.67%') + + +def test_signal_handler(mocker): + """ + Test Hyperopt.signal_handler() + """ + m = MagicMock() + mocker.patch('sys.exit', m) + mocker.patch('freqtrade.optimize.hyperopt.Hyperopt.save_trials', m) + mocker.patch('freqtrade.optimize.hyperopt.Hyperopt.log_trials_result', m) + + hyperopt = _HYPEROPT + hyperopt.signal_handler(9, None) + assert m.call_count == 3 diff --git a/freqtrade/tests/strategy/test_strategy.py b/freqtrade/tests/strategy/test_strategy.py index 8c099b502..77250659f 100644 --- a/freqtrade/tests/strategy/test_strategy.py +++ b/freqtrade/tests/strategy/test_strategy.py @@ -55,7 +55,7 @@ def test_strategy(result): strategy = Strategy({'strategy': 'default_strategy'}) assert hasattr(strategy.custom_strategy, 'minimal_roi') - assert strategy.minimal_roi['0'] == 0.04 + assert strategy.minimal_roi[0] == 0.04 assert hasattr(strategy.custom_strategy, 'stoploss') assert strategy.stoploss == -0.10 @@ -83,7 +83,7 @@ def test_strategy_override_minimal_roi(caplog): strategy = Strategy(config) assert hasattr(strategy.custom_strategy, 'minimal_roi') - assert strategy.minimal_roi['0'] == 0.5 + assert strategy.minimal_roi[0] == 0.5 assert ('freqtrade.strategy.strategy', logging.INFO, 'Override strategy \'minimal_roi\' with value in config file.' @@ -136,8 +136,8 @@ def test_strategy_singleton(): strategy1 = Strategy({'strategy': 'default_strategy'}) assert hasattr(strategy1.custom_strategy, 'minimal_roi') - assert strategy1.minimal_roi['0'] == 0.04 + assert strategy1.minimal_roi[0] == 0.04 strategy2 = Strategy() assert hasattr(strategy2.custom_strategy, 'minimal_roi') - assert strategy2.minimal_roi['0'] == 0.04 + assert strategy2.minimal_roi[0] == 0.04 diff --git a/freqtrade/tests/test_analyze.py b/freqtrade/tests/test_analyze.py index 7ada49028..36e0cd59f 100644 --- a/freqtrade/tests/test_analyze.py +++ b/freqtrade/tests/test_analyze.py @@ -43,6 +43,11 @@ def test_analyze_object() -> None: assert hasattr(Analyze, 'min_roi_reached') +def test_dataframe_correct_length(result): + dataframe = Analyze.parse_ticker_dataframe(result) + assert len(result.index) == len(dataframe.index) + + def test_dataframe_correct_columns(result): assert result.columns.tolist() == \ ['close', 'high', 'low', 'open', 'date', 'volume']