Rework backtesting --no-cahche to --cache=[none, day, week, month].

Fix an issue where config modification during runtime would prevent use of cached results.
This commit is contained in:
Rokas Kupstys 2022-01-18 11:00:51 +02:00
parent 3c06d31bbf
commit 5fffc5033a
8 changed files with 117 additions and 42 deletions

View File

@ -24,7 +24,7 @@ ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv",
ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions", ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions",
"enable_protections", "dry_run_wallet", "timeframe_detail", "enable_protections", "dry_run_wallet", "timeframe_detail",
"strategy_list", "export", "exportfilename", "strategy_list", "export", "exportfilename",
"backtest_breakdown", "no_backtest_cache"] "backtest_breakdown", "backtest_cache"]
ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path", ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",
"position_stacking", "use_max_market_positions", "position_stacking", "use_max_market_positions",

View File

@ -205,10 +205,12 @@ AVAILABLE_CLI_OPTIONS = {
nargs='+', nargs='+',
choices=constants.BACKTEST_BREAKDOWNS choices=constants.BACKTEST_BREAKDOWNS
), ),
"no_backtest_cache": Arg( "backtest_cache": Arg(
'--no-cache', '--cache',
help='Do not reuse cached backtest results.', help='Load a cached backtest result no older than specified age.',
action='store_true' metavar='AGE',
default=constants.BACKTEST_CACHE_DEFAULT,
choices=constants.BACKTEST_CACHE_AGE,
), ),
# Edge # Edge
"stoploss_range": Arg( "stoploss_range": Arg(

View File

@ -276,8 +276,8 @@ class Configuration:
self._args_to_config(config, argname='backtest_breakdown', self._args_to_config(config, argname='backtest_breakdown',
logstring='Parameter --breakdown detected ...') logstring='Parameter --breakdown detected ...')
self._args_to_config(config, argname='no_backtest_cache', self._args_to_config(config, argname='backtest_cache',
logstring='Parameter --no-cache detected ...') logstring='Parameter --cache={} detected ...')
self._args_to_config(config, argname='disableparamexport', self._args_to_config(config, argname='disableparamexport',
logstring='Parameter --disableparamexport detected: {} ...') logstring='Parameter --disableparamexport detected: {} ...')

View File

@ -34,6 +34,9 @@ AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard']
AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5'] AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5']
BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] BACKTEST_BREAKDOWNS = ['day', 'week', 'month']
BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month']
BACKTEST_CACHE_DEFAULT = 'day'
BACKTEST_CACHE_NONE = 'none'
DRY_RUN_WALLET = 1000 DRY_RUN_WALLET = 1000
DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S' DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S'
MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons

View File

@ -3,6 +3,7 @@ Helpers when analyzing backtest data
""" """
import logging import logging
from copy import copy from copy import copy
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union from typing import Any, Dict, List, Optional, Tuple, Union
@ -143,12 +144,24 @@ def load_backtest_stats(filename: Union[Path, str]) -> Dict[str, Any]:
return data return data
def find_existing_backtest_stats(dirname: Union[Path, str], def _load_and_merge_backtest_result(strategy_name: str, filename: Path, results: Dict[str, Any]):
run_ids: Dict[str, str]) -> Dict[str, Any]: bt_data = load_backtest_stats(filename)
for k in ('metadata', 'strategy'):
results[k][strategy_name] = bt_data[k][strategy_name]
comparison = bt_data['strategy_comparison']
for i in range(len(comparison)):
if comparison[i]['key'] == strategy_name:
results['strategy_comparison'].append(comparison[i])
break
def find_existing_backtest_stats(dirname: Union[Path, str], run_ids: Dict[str, str],
min_backtest_date: datetime = None) -> Dict[str, Any]:
""" """
Find existing backtest stats that match specified run IDs and load them. Find existing backtest stats that match specified run IDs and load them.
:param dirname: pathlib.Path object, or string pointing to the file. :param dirname: pathlib.Path object, or string pointing to the file.
:param run_ids: {strategy_name: id_string} dictionary. :param run_ids: {strategy_name: id_string} dictionary.
:param min_backtest_date: do not load a backtest older than specified date.
:return: results dict. :return: results dict.
""" """
# Copy so we can modify this dict without affecting parent scope. # Copy so we can modify this dict without affecting parent scope.
@ -169,18 +182,27 @@ def find_existing_backtest_stats(dirname: Union[Path, str],
break break
for strategy_name, run_id in list(run_ids.items()): for strategy_name, run_id in list(run_ids.items()):
if metadata.get(strategy_name, {}).get('run_id') == run_id: strategy_metadata = metadata.get(strategy_name, None)
# TODO: load_backtest_stats() may load an old version of backtest which is if not strategy_metadata:
# incompatible with current version. # This strategy is not present in analyzed backtest.
continue
if min_backtest_date is not None:
try:
backtest_date = strategy_metadata['backtest_start_time']
except KeyError:
# Older metadata format without backtest time, too old to consider.
return results
backtest_date = datetime.fromtimestamp(backtest_date, tz=timezone.utc)
if backtest_date < min_backtest_date:
# Do not use a cached result for this strategy as first result is too old.
del run_ids[strategy_name]
continue
if strategy_metadata['run_id'] == run_id:
del run_ids[strategy_name] del run_ids[strategy_name]
bt_data = load_backtest_stats(filename) _load_and_merge_backtest_result(strategy_name, filename, results)
for k in ('metadata', 'strategy'):
results[k][strategy_name] = bt_data[k][strategy_name]
comparison = bt_data['strategy_comparison']
for i in range(len(comparison)):
if comparison[i]['key'] == strategy_name:
results['strategy_comparison'].append(comparison[i])
break
if len(run_ids) == 0: if len(run_ids) == 0:
break break
return results return results

View File

@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple
from pandas import DataFrame from pandas import DataFrame
from freqtrade import constants
from freqtrade.configuration import TimeRange, validate_config_consistency from freqtrade.configuration import TimeRange, validate_config_consistency
from freqtrade.constants import DATETIME_PRINT_FORMAT from freqtrade.constants import DATETIME_PRINT_FORMAT
from freqtrade.data import history from freqtrade.data import history
@ -64,6 +65,7 @@ class Backtesting:
self.results: Dict[str, Any] = {} self.results: Dict[str, Any] = {}
config['dry_run'] = True config['dry_run'] = True
self.run_ids: Dict[str, str] = {}
self.strategylist: List[IStrategy] = [] self.strategylist: List[IStrategy] = []
self.all_results: Dict[str, Dict] = {} self.all_results: Dict[str, Dict] = {}
@ -728,7 +730,7 @@ class Backtesting:
) )
backtest_end_time = datetime.now(timezone.utc) backtest_end_time = datetime.now(timezone.utc)
results.update({ results.update({
'run_id': get_strategy_run_id(strat), 'run_id': self.run_ids.get(strat.get_strategy_name(), ''),
'backtest_start_time': int(backtest_start_time.timestamp()), 'backtest_start_time': int(backtest_start_time.timestamp()),
'backtest_end_time': int(backtest_end_time.timestamp()), 'backtest_end_time': int(backtest_end_time.timestamp()),
}) })
@ -736,6 +738,20 @@ class Backtesting:
return min_date, max_date return min_date, max_date
def _get_min_cached_backtest_date(self):
min_backtest_date = None
backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT)
if self.timerange.stopts == 0 or datetime.fromtimestamp(
self.timerange.stopts, tz=timezone.utc) > datetime.now(tz=timezone.utc):
logger.warning('Backtest result caching disabled due to use of open-ended timerange.')
elif backtest_cache_age == 'day':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
elif backtest_cache_age == 'week':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1)
elif backtest_cache_age == 'month':
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4)
return min_backtest_date
def start(self) -> None: def start(self) -> None:
""" """
Run backtesting end-to-end Run backtesting end-to-end
@ -747,21 +763,17 @@ class Backtesting:
self.load_bt_data_detail() self.load_bt_data_detail()
logger.info("Dataload complete. Calculating indicators") logger.info("Dataload complete. Calculating indicators")
run_ids = { self.run_ids = {
strategy.get_strategy_name(): get_strategy_run_id(strategy) strategy.get_strategy_name(): get_strategy_run_id(strategy)
for strategy in self.strategylist for strategy in self.strategylist
} }
# Load previous result that will be updated incrementally. # Load previous result that will be updated incrementally.
# This can be circumvented in certain instances in combination with downloading more data # This can be circumvented in certain instances in combination with downloading more data
if self.timerange.stopts == 0 or datetime.fromtimestamp( min_backtest_date = self._get_min_cached_backtest_date()
self.timerange.stopts, tz=timezone.utc) > datetime.now(tz=timezone.utc): if min_backtest_date is not None:
self.config['no_backtest_cache'] = True
logger.warning('Backtest result caching disabled due to use of open-ended timerange.')
if not self.config.get('no_backtest_cache', False):
self.results = find_existing_backtest_stats( self.results = find_existing_backtest_stats(
self.config['user_data_dir'] / 'backtest_results', run_ids) self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date)
for strat in self.strategylist: for strat in self.strategylist:
if self.results and strat.get_strategy_name() in self.results['strategy']: if self.results and strat.get_strategy_name() in self.results['strategy']:

View File

@ -527,7 +527,8 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame],
strat_stats = generate_strategy_stats(pairlist, strategy, content, strat_stats = generate_strategy_stats(pairlist, strategy, content,
min_date, max_date, market_change=market_change) min_date, max_date, market_change=market_change)
metadata[strategy] = { metadata[strategy] = {
'run_id': content['run_id'] 'run_id': content['run_id'],
'backtest_start_time': content['backtest_start_time'],
} }
result['strategy'][strategy] = strat_stats result['strategy'][strategy] = strat_stats

View File

@ -11,6 +11,7 @@ import pandas as pd
import pytest import pytest
from arrow import Arrow from arrow import Arrow
from freqtrade import constants
from freqtrade.commands.optimize_commands import setup_optimize_configuration, start_backtesting from freqtrade.commands.optimize_commands import setup_optimize_configuration, start_backtesting
from freqtrade.configuration import TimeRange from freqtrade.configuration import TimeRange
from freqtrade.data import history from freqtrade.data import history
@ -1242,8 +1243,11 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker,
@pytest.mark.filterwarnings("ignore:deprecated") @pytest.mark.filterwarnings("ignore:deprecated")
def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testdatadir): @pytest.mark.parametrize('run_id', ['2', 'changed'])
@pytest.mark.parametrize('start_delta', [{'days': 0}, {'days': 1}, {'weeks': 1}, {'weeks': 4}])
@pytest.mark.parametrize('cache', constants.BACKTEST_CACHE_AGE)
def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testdatadir, run_id,
start_delta, cache):
default_conf.update({ default_conf.update({
"use_sell_signal": True, "use_sell_signal": True,
"sell_profit_only": False, "sell_profit_only": False,
@ -1263,9 +1267,19 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda
mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', backtestmock) mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', backtestmock)
mocker.patch('freqtrade.optimize.backtesting.show_backtest_results', MagicMock()) mocker.patch('freqtrade.optimize.backtesting.show_backtest_results', MagicMock())
now = min_backtest_date = datetime.now(tz=timezone.utc)
start_time = now - timedelta(**start_delta) + timedelta(hours=1)
if cache == 'none':
min_backtest_date = now + timedelta(days=1)
elif cache == 'day':
min_backtest_date = now - timedelta(days=1)
elif cache == 'week':
min_backtest_date = now - timedelta(weeks=1)
elif cache == 'month':
min_backtest_date = now - timedelta(weeks=4)
load_backtest_metadata = MagicMock(return_value={ load_backtest_metadata = MagicMock(return_value={
'StrategyTestV2': {'run_id': '1'}, 'StrategyTestV2': {'run_id': '1', 'backtest_start_time': now.timestamp()},
'TestStrategyLegacyV1': {'run_id': 'changed'} 'TestStrategyLegacyV1': {'run_id': run_id, 'backtest_start_time': start_time.timestamp()}
}) })
load_backtest_stats = MagicMock(side_effect=[ load_backtest_stats = MagicMock(side_effect=[
{ {
@ -1279,7 +1293,8 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda
'strategy_comparison': [{'key': 'TestStrategyLegacyV1'}] 'strategy_comparison': [{'key': 'TestStrategyLegacyV1'}]
} }
]) ])
mocker.patch('pathlib.Path.glob', return_value=['not important']) mocker.patch('pathlib.Path.glob', return_value=[
Path(datetime.strftime(datetime.now(), 'backtest-result-%Y-%m-%d_%H-%M-%S.json'))])
mocker.patch.multiple('freqtrade.data.btanalysis', mocker.patch.multiple('freqtrade.data.btanalysis',
load_backtest_metadata=load_backtest_metadata, load_backtest_metadata=load_backtest_metadata,
load_backtest_stats=load_backtest_stats) load_backtest_stats=load_backtest_stats)
@ -1296,29 +1311,49 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda
'--timerange', '1510694220-1510700340', '--timerange', '1510694220-1510700340',
'--enable-position-stacking', '--enable-position-stacking',
'--disable-max-market-positions', '--disable-max-market-positions',
'--cache', cache,
'--strategy-list', '--strategy-list',
'StrategyTestV2', 'StrategyTestV2',
'TestStrategyLegacyV1', 'TestStrategyLegacyV1',
] ]
args = get_args(args) args = get_args(args)
start_backtesting(args) start_backtesting(args)
# 1 backtest, 1 loaded from cache
assert backtestmock.call_count == 1
# check the logs, that will contain the backtest result # check the logs, that will contain the backtest result
exists = [ exists = [
'Parameter -i/--timeframe detected ... Using timeframe: 1m ...', 'Parameter -i/--timeframe detected ... Using timeframe: 1m ...',
'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} ...',
'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 '
'up to 2017-11-14 22:58:00 (0 days).',
'Parameter --enable-position-stacking detected ...', 'Parameter --enable-position-stacking detected ...',
'Reusing result of previous backtest for StrategyTestV2',
'Running backtesting for Strategy TestStrategyLegacyV1',
] ]
for line in exists: for line in exists:
assert log_has(line, caplog) assert log_has(line, caplog)
if cache == 'none':
assert backtestmock.call_count == 2
exists = [
'Running backtesting for Strategy StrategyTestV2',
'Running backtesting for Strategy TestStrategyLegacyV1',
'Ignoring max_open_trades (--disable-max-market-positions was used) ...',
'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:58:00 (0 days).',
]
elif run_id == '2' and min_backtest_date < start_time:
assert backtestmock.call_count == 0
exists = [
'Reusing result of previous backtest for StrategyTestV2',
'Reusing result of previous backtest for TestStrategyLegacyV1',
]
else:
exists = [
'Reusing result of previous backtest for StrategyTestV2',
'Running backtesting for Strategy TestStrategyLegacyV1',
'Ignoring max_open_trades (--disable-max-market-positions was used) ...',
'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:58:00 (0 days).',
]
assert backtestmock.call_count == 1
for line in exists:
assert log_has(line, caplog)