Merge pull request #4215 from freqtrade/refactor/backtest
Small backtest refactor, introduce calling `bot_loop_start` in backtesting
This commit is contained in:
		| @@ -49,8 +49,9 @@ This loop will be repeated again and again until the bot is stopped. | |||||||
| [backtesting](backtesting.md) or [hyperopt](hyperopt.md) do only part of the above logic, since most of the trading operations are fully simulated. | [backtesting](backtesting.md) or [hyperopt](hyperopt.md) do only part of the above logic, since most of the trading operations are fully simulated. | ||||||
|  |  | ||||||
| * Load historic data for configured pairlist. | * Load historic data for configured pairlist. | ||||||
| * Calculate indicators (calls `populate_indicators()`). | * Calls `bot_loop_start()` once. | ||||||
| * Calls `populate_buy_trend()` and `populate_sell_trend()` | * Calculate indicators (calls `populate_indicators()` once per pair). | ||||||
|  | * Calculate buy / sell signals (calls `populate_buy_trend()` and `populate_sell_trend()` once per pair) | ||||||
| * Loops per candle simulating entry and exit points. | * Loops per candle simulating entry and exit points. | ||||||
| * Generate backtest report output | * Generate backtest report output | ||||||
|  |  | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ This module contains the backtesting logic | |||||||
| import logging | import logging | ||||||
| from collections import defaultdict | from collections import defaultdict | ||||||
| from copy import deepcopy | from copy import deepcopy | ||||||
| from datetime import datetime, timedelta | from datetime import datetime, timedelta, timezone | ||||||
| from typing import Any, Dict, List, NamedTuple, Optional, Tuple | from typing import Any, Dict, List, NamedTuple, Optional, Tuple | ||||||
|  |  | ||||||
| from pandas import DataFrame | from pandas import DataFrame | ||||||
| @@ -26,6 +26,7 @@ from freqtrade.plugins.pairlistmanager import PairListManager | |||||||
| from freqtrade.plugins.protectionmanager import ProtectionManager | from freqtrade.plugins.protectionmanager import ProtectionManager | ||||||
| from freqtrade.resolvers import ExchangeResolver, StrategyResolver | from freqtrade.resolvers import ExchangeResolver, StrategyResolver | ||||||
| from freqtrade.strategy.interface import IStrategy, SellCheckTuple, SellType | from freqtrade.strategy.interface import IStrategy, SellCheckTuple, SellType | ||||||
|  | from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper | ||||||
|  |  | ||||||
|  |  | ||||||
| logger = logging.getLogger(__name__) | logger = logging.getLogger(__name__) | ||||||
| @@ -76,6 +77,8 @@ class Backtesting: | |||||||
|         # Reset keys for backtesting |         # Reset keys for backtesting | ||||||
|         remove_credentials(self.config) |         remove_credentials(self.config) | ||||||
|         self.strategylist: List[IStrategy] = [] |         self.strategylist: List[IStrategy] = [] | ||||||
|  |         self.all_results: Dict[str, Dict] = {} | ||||||
|  |  | ||||||
|         self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) |         self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) | ||||||
|  |  | ||||||
|         dataprovider = DataProvider(self.config, self.exchange) |         dataprovider = DataProvider(self.config, self.exchange) | ||||||
| @@ -150,6 +153,10 @@ class Backtesting: | |||||||
|         self.strategy.order_types['stoploss_on_exchange'] = False |         self.strategy.order_types['stoploss_on_exchange'] = False | ||||||
|  |  | ||||||
|     def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]: |     def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]: | ||||||
|  |         """ | ||||||
|  |         Loads backtest data and returns the data combined with the timerange | ||||||
|  |         as tuple. | ||||||
|  |         """ | ||||||
|         timerange = TimeRange.parse_timerange(None if self.config.get( |         timerange = TimeRange.parse_timerange(None if self.config.get( | ||||||
|             'timerange') is None else str(self.config.get('timerange'))) |             'timerange') is None else str(self.config.get('timerange'))) | ||||||
|  |  | ||||||
| @@ -424,6 +431,53 @@ class Backtesting: | |||||||
|  |  | ||||||
|         return DataFrame.from_records(trades, columns=BacktestResult._fields) |         return DataFrame.from_records(trades, columns=BacktestResult._fields) | ||||||
|  |  | ||||||
|  |     def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, Any], timerange: TimeRange): | ||||||
|  |         logger.info("Running backtesting for Strategy %s", strat.get_strategy_name()) | ||||||
|  |         backtest_start_time = datetime.now(timezone.utc) | ||||||
|  |         self._set_strategy(strat) | ||||||
|  |  | ||||||
|  |         strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() | ||||||
|  |  | ||||||
|  |         # Use max_open_trades in backtesting, except --disable-max-market-positions is set | ||||||
|  |         if self.config.get('use_max_market_positions', True): | ||||||
|  |             # Must come from strategy config, as the strategy may modify this setting. | ||||||
|  |             max_open_trades = self.strategy.config['max_open_trades'] | ||||||
|  |         else: | ||||||
|  |             logger.info( | ||||||
|  |                 'Ignoring max_open_trades (--disable-max-market-positions was used) ...') | ||||||
|  |             max_open_trades = 0 | ||||||
|  |  | ||||||
|  |         # need to reprocess data every time to populate signals | ||||||
|  |         preprocessed = self.strategy.ohlcvdata_to_dataframe(data) | ||||||
|  |  | ||||||
|  |         # Trim startup period from analyzed dataframe | ||||||
|  |         for pair, df in preprocessed.items(): | ||||||
|  |             preprocessed[pair] = trim_dataframe(df, timerange) | ||||||
|  |         min_date, max_date = history.get_timerange(preprocessed) | ||||||
|  |  | ||||||
|  |         logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' | ||||||
|  |                     f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' | ||||||
|  |                     f'({(max_date - min_date).days} days)..') | ||||||
|  |         # Execute backtest and store results | ||||||
|  |         results = self.backtest( | ||||||
|  |             processed=preprocessed, | ||||||
|  |             stake_amount=self.config['stake_amount'], | ||||||
|  |             start_date=min_date.datetime, | ||||||
|  |             end_date=max_date.datetime, | ||||||
|  |             max_open_trades=max_open_trades, | ||||||
|  |             position_stacking=self.config.get('position_stacking', False), | ||||||
|  |             enable_protections=self.config.get('enable_protections', False), | ||||||
|  |         ) | ||||||
|  |         backtest_end_time = datetime.now(timezone.utc) | ||||||
|  |         self.all_results[self.strategy.get_strategy_name()] = { | ||||||
|  |             'results': results, | ||||||
|  |             'config': self.strategy.config, | ||||||
|  |             'locks': PairLocks.locks, | ||||||
|  |             'backtest_start_time': int(backtest_start_time.timestamp()), | ||||||
|  |             'backtest_end_time': int(backtest_end_time.timestamp()), | ||||||
|  |         } | ||||||
|  |         return min_date, max_date | ||||||
|  |  | ||||||
|     def start(self) -> None: |     def start(self) -> None: | ||||||
|         """ |         """ | ||||||
|         Run backtesting end-to-end |         Run backtesting end-to-end | ||||||
| @@ -431,55 +485,15 @@ class Backtesting: | |||||||
|         """ |         """ | ||||||
|         data: Dict[str, Any] = {} |         data: Dict[str, Any] = {} | ||||||
|  |  | ||||||
|         logger.info('Using stake_currency: %s ...', self.config['stake_currency']) |  | ||||||
|         logger.info('Using stake_amount: %s ...', self.config['stake_amount']) |  | ||||||
|  |  | ||||||
|         position_stacking = self.config.get('position_stacking', False) |  | ||||||
|  |  | ||||||
|         data, timerange = self.load_bt_data() |         data, timerange = self.load_bt_data() | ||||||
|  |  | ||||||
|         all_results = {} |         min_date = None | ||||||
|  |         max_date = None | ||||||
|         for strat in self.strategylist: |         for strat in self.strategylist: | ||||||
|             logger.info("Running backtesting for Strategy %s", strat.get_strategy_name()) |             min_date, max_date = self.backtest_one_strategy(strat, data, timerange) | ||||||
|             self._set_strategy(strat) |  | ||||||
|  |  | ||||||
|             # Use max_open_trades in backtesting, except --disable-max-market-positions is set |         stats = generate_backtest_stats(data, self.all_results, | ||||||
|             if self.config.get('use_max_market_positions', True): |                                         min_date=min_date, max_date=max_date) | ||||||
|                 # Must come from strategy config, as the strategy may modify this setting. |  | ||||||
|                 max_open_trades = self.strategy.config['max_open_trades'] |  | ||||||
|             else: |  | ||||||
|                 logger.info( |  | ||||||
|                     'Ignoring max_open_trades (--disable-max-market-positions was used) ...') |  | ||||||
|                 max_open_trades = 0 |  | ||||||
|  |  | ||||||
|             # need to reprocess data every time to populate signals |  | ||||||
|             preprocessed = self.strategy.ohlcvdata_to_dataframe(data) |  | ||||||
|  |  | ||||||
|             # Trim startup period from analyzed dataframe |  | ||||||
|             for pair, df in preprocessed.items(): |  | ||||||
|                 preprocessed[pair] = trim_dataframe(df, timerange) |  | ||||||
|             min_date, max_date = history.get_timerange(preprocessed) |  | ||||||
|  |  | ||||||
|             logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' |  | ||||||
|                         f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' |  | ||||||
|                         f'({(max_date - min_date).days} days)..') |  | ||||||
|             # Execute backtest and print results |  | ||||||
|             results = self.backtest( |  | ||||||
|                 processed=preprocessed, |  | ||||||
|                 stake_amount=self.config['stake_amount'], |  | ||||||
|                 start_date=min_date.datetime, |  | ||||||
|                 end_date=max_date.datetime, |  | ||||||
|                 max_open_trades=max_open_trades, |  | ||||||
|                 position_stacking=position_stacking, |  | ||||||
|                 enable_protections=self.config.get('enable_protections', False), |  | ||||||
|             ) |  | ||||||
|             all_results[self.strategy.get_strategy_name()] = { |  | ||||||
|                 'results': results, |  | ||||||
|                 'config': self.strategy.config, |  | ||||||
|                 'locks': PairLocks.locks, |  | ||||||
|             } |  | ||||||
|  |  | ||||||
|         stats = generate_backtest_stats(data, all_results, min_date=min_date, max_date=max_date) |  | ||||||
|  |  | ||||||
|         if self.config.get('export', False): |         if self.config.get('export', False): | ||||||
|             store_backtest_stats(self.config['exportfilename'], stats) |             store_backtest_stats(self.config['exportfilename'], stats) | ||||||
|   | |||||||
| @@ -650,7 +650,7 @@ class Hyperopt: | |||||||
|         # Trim startup period from analyzed dataframe |         # Trim startup period from analyzed dataframe | ||||||
|         for pair, df in preprocessed.items(): |         for pair, df in preprocessed.items(): | ||||||
|             preprocessed[pair] = trim_dataframe(df, timerange) |             preprocessed[pair] = trim_dataframe(df, timerange) | ||||||
|         min_date, max_date = get_timerange(data) |         min_date, max_date = get_timerange(preprocessed) | ||||||
|  |  | ||||||
|         logger.info(f'Hyperopting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' |         logger.info(f'Hyperopting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' | ||||||
|                     f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' |                     f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' | ||||||
|   | |||||||
| @@ -282,6 +282,9 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame], | |||||||
|             'backtest_end_ts': max_date.int_timestamp * 1000, |             'backtest_end_ts': max_date.int_timestamp * 1000, | ||||||
|             'backtest_days': backtest_days, |             'backtest_days': backtest_days, | ||||||
|  |  | ||||||
|  |             'backtest_run_start_ts': content['backtest_start_time'], | ||||||
|  |             'backtest_run_end_ts': content['backtest_end_time'], | ||||||
|  |  | ||||||
|             'trades_per_day': round(len(results) / backtest_days, 2) if backtest_days > 0 else 0, |             'trades_per_day': round(len(results) / backtest_days, 2) if backtest_days > 0 else 0, | ||||||
|             'market_change': market_change, |             'market_change': market_change, | ||||||
|             'pairlist': list(btdata.keys()), |             'pairlist': list(btdata.keys()), | ||||||
| @@ -290,6 +293,9 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame], | |||||||
|             'max_open_trades': (config['max_open_trades'] |             'max_open_trades': (config['max_open_trades'] | ||||||
|                                 if config['max_open_trades'] != float('inf') else -1), |                                 if config['max_open_trades'] != float('inf') else -1), | ||||||
|             'timeframe': config['timeframe'], |             'timeframe': config['timeframe'], | ||||||
|  |             'timerange': config.get('timerange', ''), | ||||||
|  |             'enable_protections': config.get('enable_protections', False), | ||||||
|  |             'strategy_name': strategy, | ||||||
|             # Parameters relevant for backtesting |             # Parameters relevant for backtesting | ||||||
|             'stoploss': config['stoploss'], |             'stoploss': config['stoploss'], | ||||||
|             'trailing_stop': config.get('trailing_stop', False), |             'trailing_stop': config.get('trailing_stop', False), | ||||||
|   | |||||||
| @@ -350,17 +350,17 @@ def test_backtesting_start(default_conf, mocker, testdatadir, caplog) -> None: | |||||||
|     default_conf['timerange'] = '-1510694220' |     default_conf['timerange'] = '-1510694220' | ||||||
|  |  | ||||||
|     backtesting = Backtesting(default_conf) |     backtesting = Backtesting(default_conf) | ||||||
|  |     backtesting.strategy.bot_loop_start = MagicMock() | ||||||
|     backtesting.start() |     backtesting.start() | ||||||
|     # check the logs, that will contain the backtest result |     # check the logs, that will contain the backtest result | ||||||
|     exists = [ |     exists = [ | ||||||
|         'Using stake_currency: BTC ...', |  | ||||||
|         'Using stake_amount: 0.001 ...', |  | ||||||
|         'Backtesting with data from 2017-11-14 21:17:00 ' |         'Backtesting with data from 2017-11-14 21:17:00 ' | ||||||
|         'up to 2017-11-14 22:59:00 (0 days)..' |         'up to 2017-11-14 22:59:00 (0 days)..' | ||||||
|     ] |     ] | ||||||
|     for line in exists: |     for line in exists: | ||||||
|         assert log_has(line, caplog) |         assert log_has(line, caplog) | ||||||
|     assert backtesting.strategy.dp._pairlists is not None |     assert backtesting.strategy.dp._pairlists is not None | ||||||
|  |     assert backtesting.strategy.bot_loop_start.call_count == 1 | ||||||
|  |  | ||||||
|  |  | ||||||
| def test_backtesting_start_no_data(default_conf, mocker, caplog, testdatadir) -> None: | def test_backtesting_start_no_data(default_conf, mocker, caplog, testdatadir) -> None: | ||||||
| @@ -722,8 +722,6 @@ def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): | |||||||
|         'Ignoring max_open_trades (--disable-max-market-positions was used) ...', |         'Ignoring max_open_trades (--disable-max-market-positions was used) ...', | ||||||
|         'Parameter --timerange detected: 1510694220-1510700340 ...', |         'Parameter --timerange detected: 1510694220-1510700340 ...', | ||||||
|         f'Using data directory: {testdatadir} ...', |         f'Using data directory: {testdatadir} ...', | ||||||
|         'Using stake_currency: BTC ...', |  | ||||||
|         'Using stake_amount: 0.001 ...', |  | ||||||
|         'Loading data from 2017-11-14 20:57:00 ' |         'Loading data from 2017-11-14 20:57:00 ' | ||||||
|         'up to 2017-11-14 22:58:00 (0 days)..', |         'up to 2017-11-14 22:58:00 (0 days)..', | ||||||
|         'Backtesting with data from 2017-11-14 21:17:00 ' |         'Backtesting with data from 2017-11-14 21:17:00 ' | ||||||
| @@ -786,8 +784,6 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): | |||||||
|         'Ignoring max_open_trades (--disable-max-market-positions was used) ...', |         'Ignoring max_open_trades (--disable-max-market-positions was used) ...', | ||||||
|         'Parameter --timerange detected: 1510694220-1510700340 ...', |         'Parameter --timerange detected: 1510694220-1510700340 ...', | ||||||
|         f'Using data directory: {testdatadir} ...', |         f'Using data directory: {testdatadir} ...', | ||||||
|         'Using stake_currency: BTC ...', |  | ||||||
|         'Using stake_amount: 0.001 ...', |  | ||||||
|         'Loading data from 2017-11-14 20:57:00 ' |         'Loading data from 2017-11-14 20:57:00 ' | ||||||
|         'up to 2017-11-14 22:58:00 (0 days)..', |         'up to 2017-11-14 22:58:00 (0 days)..', | ||||||
|         'Backtesting with data from 2017-11-14 21:17:00 ' |         'Backtesting with data from 2017-11-14 21:17:00 ' | ||||||
| @@ -865,8 +861,6 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat | |||||||
|         'Ignoring max_open_trades (--disable-max-market-positions was used) ...', |         'Ignoring max_open_trades (--disable-max-market-positions was used) ...', | ||||||
|         'Parameter --timerange detected: 1510694220-1510700340 ...', |         'Parameter --timerange detected: 1510694220-1510700340 ...', | ||||||
|         f'Using data directory: {testdatadir} ...', |         f'Using data directory: {testdatadir} ...', | ||||||
|         'Using stake_currency: BTC ...', |  | ||||||
|         'Using stake_amount: 0.001 ...', |  | ||||||
|         'Loading data from 2017-11-14 20:57:00 ' |         'Loading data from 2017-11-14 20:57:00 ' | ||||||
|         'up to 2017-11-14 22:58:00 (0 days)..', |         'up to 2017-11-14 22:58:00 (0 days)..', | ||||||
|         'Backtesting with data from 2017-11-14 21:17:00 ' |         'Backtesting with data from 2017-11-14 21:17:00 ' | ||||||
|   | |||||||
| @@ -77,7 +77,10 @@ def test_generate_backtest_stats(default_conf, testdatadir): | |||||||
|                                                  SellType.ROI, SellType.FORCE_SELL] |                                                  SellType.ROI, SellType.FORCE_SELL] | ||||||
|                                  }), |                                  }), | ||||||
|         'config': default_conf, |         'config': default_conf, | ||||||
|         'locks': []} |         'locks': [], | ||||||
|  |         'backtest_start_time': Arrow.utcnow().int_timestamp, | ||||||
|  |         'backtest_end_time': Arrow.utcnow().int_timestamp, | ||||||
|  |         } | ||||||
|         } |         } | ||||||
|     timerange = TimeRange.parse_timerange('1510688220-1510700340') |     timerange = TimeRange.parse_timerange('1510688220-1510700340') | ||||||
|     min_date = Arrow.fromtimestamp(1510688220) |     min_date = Arrow.fromtimestamp(1510688220) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user