stable/freqtrade/optimize/hyperopt.py

520 lines
23 KiB
Python
Raw Normal View History

# pragma pylint: disable=too-many-instance-attributes, pointless-string-statement
"""
This module contains the hyperopt logic
"""
import logging
import random
import warnings
from datetime import datetime, timezone
2020-08-08 15:04:32 +00:00
from math import ceil
2019-01-06 13:47:38 +00:00
from pathlib import Path
2019-08-02 19:22:58 +00:00
from typing import Any, Dict, List, Optional
2020-08-08 15:04:32 +00:00
import progressbar
2021-05-12 03:58:25 +00:00
import rapidjson
2019-08-09 11:48:57 +00:00
from colorama import Fore, Style
from colorama import init as colorama_init
2020-09-28 17:39:41 +00:00
from joblib import Parallel, cpu_count, delayed, dump, load, wrap_non_picklable_objects
from pandas import DataFrame
2018-06-18 19:40:36 +00:00
from freqtrade.constants import DATETIME_PRINT_FORMAT, FTHYPT_FILEVERSION, LAST_BT_RESULT_FN
from freqtrade.data.converter import trim_dataframes
from freqtrade.data.history import get_timerange
from freqtrade.misc import deep_merge_dicts, file_dump_json, plural
2018-03-02 15:22:00 +00:00
from freqtrade.optimize.backtesting import Backtesting
2019-08-14 10:25:49 +00:00
# Import IHyperOpt and IHyperOptLoss to allow unpickling classes from these modules
from freqtrade.optimize.hyperopt_auto import HyperOptAuto
from freqtrade.optimize.hyperopt_interface import IHyperOpt # noqa: F401
2020-09-28 17:39:41 +00:00
from freqtrade.optimize.hyperopt_loss_interface import IHyperOptLoss # noqa: F401
from freqtrade.optimize.hyperopt_tools import HyperoptTools, hyperopt_serializer
from freqtrade.optimize.optimize_reports import generate_strategy_stats
2020-09-28 17:39:41 +00:00
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver, HyperOptResolver
2020-09-28 17:39:41 +00:00
2019-12-10 15:10:51 +00:00
# Suppress scikit-learn FutureWarnings from skopt
with warnings.catch_warnings():
warnings.filterwarnings("ignore", category=FutureWarning)
from skopt import Optimizer
from skopt.space import Dimension
progressbar.streams.wrap_stderr()
progressbar.streams.wrap_stdout()
2018-03-25 19:37:14 +00:00
logger = logging.getLogger(__name__)
2019-05-10 07:54:44 +00:00
INITIAL_POINTS = 30
# Keep no more than SKOPT_MODEL_QUEUE_SIZE models
# in the skopt model queue, to optimize memory consumption
SKOPT_MODEL_QUEUE_SIZE = 10
MAX_LOSS = 100000 # just a big enough number to be bad result in loss optimization
2018-03-25 19:37:14 +00:00
class Hyperopt:
"""
Hyperopt class, this class contains all the logic to run a hyperopt simulation
To run a backtest:
hyperopt = Hyperopt(config)
hyperopt.start()
"""
2021-03-27 17:00:07 +00:00
custom_hyperopt: IHyperOpt
2020-01-31 21:37:05 +00:00
def __init__(self, config: Dict[str, Any]) -> None:
self.buy_space: List[Dimension] = []
self.sell_space: List[Dimension] = []
2021-08-03 05:10:04 +00:00
self.protection_space: List[Dimension] = []
self.roi_space: List[Dimension] = []
self.stoploss_space: List[Dimension] = []
self.trailing_space: List[Dimension] = []
self.dimensions: List[Dimension] = []
self.config = config
2019-09-18 19:57:17 +00:00
self.backtesting = Backtesting(self.config)
if not self.config.get('hyperopt'):
self.custom_hyperopt = HyperOptAuto(self.config)
self.auto_hyperopt = True
else:
self.custom_hyperopt = HyperOptResolver.load_hyperopt(self.config)
self.auto_hyperopt = False
self.backtesting._set_strategy(self.backtesting.strategylist[0])
self.custom_hyperopt.strategy = self.backtesting.strategy
self.custom_hyperoptloss = HyperOptLossResolver.load_hyperoptloss(self.config)
2019-07-16 04:27:23 +00:00
self.calculate_loss = self.custom_hyperoptloss.hyperopt_loss_function
2020-09-27 14:33:26 +00:00
time_now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
strategy = str(self.config['strategy'])
self.results_file: Path = (self.config['user_data_dir'] / 'hyperopt_results' /
f'strategy_{strategy}_{time_now}.fthypt')
self.data_pickle_file = (self.config['user_data_dir'] /
'hyperopt_results' / 'hyperopt_tickerdata.pkl')
self.total_epochs = config.get('epochs', 0)
self.current_best_loss = 100
2020-09-27 14:18:28 +00:00
self.clean_hyperopt()
2019-07-16 03:50:27 +00:00
2020-04-28 19:56:19 +00:00
self.num_epochs_saved = 0
2021-05-12 03:58:25 +00:00
self.current_best_epoch: Optional[Dict[str, Any]] = None
if not self.auto_hyperopt:
2021-08-03 05:21:11 +00:00
# Populate "fallback" functions here
# (hasattr is slow so should not be run during "regular" operations)
if hasattr(self.custom_hyperopt, 'populate_indicators'):
self.backtesting.strategy.advise_indicators = ( # type: ignore
self.custom_hyperopt.populate_indicators) # type: ignore
if hasattr(self.custom_hyperopt, 'populate_buy_trend'):
self.backtesting.strategy.advise_buy = ( # type: ignore
self.custom_hyperopt.populate_buy_trend) # type: ignore
if hasattr(self.custom_hyperopt, 'populate_sell_trend'):
self.backtesting.strategy.advise_sell = ( # type: ignore
self.custom_hyperopt.populate_sell_trend) # type: ignore
2019-08-02 19:22:58 +00:00
# Use max_open_trades for hyperopt as well, except --disable-max-market-positions is set
if self.config.get('use_max_market_positions', True):
self.max_open_trades = self.config['max_open_trades']
else:
logger.debug('Ignoring max_open_trades (--disable-max-market-positions was used) ...')
self.max_open_trades = 0
2019-09-25 00:41:22 +00:00
self.position_stacking = self.config.get('position_stacking', False)
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'sell'):
# Make sure use_sell_signal is enabled
if 'ask_strategy' not in self.config:
self.config['ask_strategy'] = {}
self.config['ask_strategy']['use_sell_signal'] = True
2019-08-01 20:57:26 +00:00
self.print_all = self.config.get('print_all', False)
self.hyperopt_table_header = 0
self.print_colorized = self.config.get('print_colorized', False)
self.print_json = self.config.get('print_json', False)
2019-07-21 14:07:06 +00:00
@staticmethod
2020-02-02 04:00:40 +00:00
def get_lock_filename(config: Dict[str, Any]) -> str:
2019-07-21 14:07:06 +00:00
return str(config['user_data_dir'] / 'hyperopt.lock')
2020-02-02 04:00:40 +00:00
def clean_hyperopt(self) -> None:
"""
Remove hyperopt pickle files to restart hyperopt.
"""
2020-04-28 19:56:19 +00:00
for f in [self.data_pickle_file, self.results_file]:
p = Path(f)
if p.is_file():
logger.info(f"Removing `{p}`.")
p.unlink()
def _get_params_dict(self, dimensions: List[Dimension], raw_params: List[Any]) -> Dict:
2019-09-16 18:22:07 +00:00
2018-06-19 06:09:54 +00:00
# Ensure the number of dimensions match
# the number of parameters in the list.
if len(raw_params) != len(dimensions):
raise ValueError('Mismatch in number of search-space dimensions.')
2018-06-19 06:09:54 +00:00
# Return a dict where the keys are the names of the dimensions
# and the values are taken from the list of parameters.
return {d.name: v for d, v in zip(dimensions, raw_params)}
2018-06-19 06:09:54 +00:00
2021-05-12 03:58:25 +00:00
def _save_result(self, epoch: Dict) -> None:
"""
2020-04-28 19:56:19 +00:00
Save hyperopt results to file
2021-05-12 03:58:25 +00:00
Store one line per epoch.
While not a valid json object - this allows appending easily.
:param epoch: result dictionary for this epoch.
"""
epoch[FTHYPT_FILEVERSION] = 2
2021-05-12 03:58:25 +00:00
with self.results_file.open('a') as f:
rapidjson.dump(epoch, f, default=hyperopt_serializer,
2021-05-24 05:48:36 +00:00
number_mode=rapidjson.NM_NATIVE | rapidjson.NM_NAN)
2021-05-15 05:01:32 +00:00
f.write("\n")
2021-05-12 03:58:25 +00:00
self.num_epochs_saved += 1
logger.debug(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
f"saved to '{self.results_file}'.")
# Store hyperopt filename
latest_filename = Path.joinpath(self.results_file.parent, LAST_BT_RESULT_FN)
file_dump_json(latest_filename, {'latest_hyperopt': str(self.results_file.name)},
log=False)
def _get_params_details(self, params: Dict) -> Dict:
"""
Return the params for each space
"""
result: Dict = {}
2019-11-23 08:32:33 +00:00
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'buy'):
result['buy'] = {p.name: params.get(p.name) for p in self.buy_space}
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'sell'):
result['sell'] = {p.name: params.get(p.name) for p in self.sell_space}
2021-08-03 05:10:04 +00:00
if HyperoptTools.has_space(self.config, 'protection'):
result['protection'] = {p.name: params.get(p.name) for p in self.protection_space}
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'roi'):
result['roi'] = {str(k): v for k, v in
self.custom_hyperopt.generate_roi_table(params).items()}
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'stoploss'):
result['stoploss'] = {p.name: params.get(p.name) for p in self.stoploss_space}
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'trailing'):
result['trailing'] = self.custom_hyperopt.generate_trailing_params(params)
2019-08-15 18:39:04 +00:00
return result
2019-11-23 08:32:33 +00:00
def _get_no_optimize_details(self) -> Dict[str, Any]:
"""
Get non-optimized parameters
"""
result: Dict[str, Any] = {}
strategy = self.backtesting.strategy
if not HyperoptTools.has_space(self.config, 'roi'):
result['roi'] = {str(k): v for k, v in strategy.minimal_roi.items()}
if not HyperoptTools.has_space(self.config, 'stoploss'):
result['stoploss'] = {'stoploss': strategy.stoploss}
if not HyperoptTools.has_space(self.config, 'trailing'):
result['trailing'] = {
'trailing_stop': strategy.trailing_stop,
'trailing_stop_positive': strategy.trailing_stop_positive,
'trailing_stop_positive_offset': strategy.trailing_stop_positive_offset,
'trailing_only_offset_is_reached': strategy.trailing_only_offset_is_reached,
}
return result
def print_results(self, results) -> None:
"""
Log results if it is better than any previous evaluation
TODO: this should be moved to HyperoptTools too
"""
is_best = results['is_best']
2019-11-23 08:32:33 +00:00
if self.print_all or is_best:
print(
HyperoptTools.get_result_table(
2020-03-11 21:30:36 +00:00
self.config, results, self.total_epochs,
self.print_all, self.print_colorized,
self.hyperopt_table_header
)
)
self.hyperopt_table_header = 2
def init_spaces(self):
"""
Assign the dimensions in the hyperoptimization space.
"""
2021-08-03 05:10:04 +00:00
if self.auto_hyperopt and HyperoptTools.has_space(self.config, 'protection'):
# Protections can only be optimized when using the Parameter interface
logger.debug("Hyperopt has 'protection' space")
# Enable Protections if protection space is selected.
self.config['enable_protections'] = True
2021-08-03 05:10:04 +00:00
self.protection_space = self.custom_hyperopt.protection_space()
2019-11-07 22:55:14 +00:00
if HyperoptTools.has_space(self.config, 'buy'):
2019-08-01 20:57:26 +00:00
logger.debug("Hyperopt has 'buy' space")
self.buy_space = self.custom_hyperopt.indicator_space()
2019-11-07 22:55:14 +00:00
if HyperoptTools.has_space(self.config, 'sell'):
2019-08-01 20:57:26 +00:00
logger.debug("Hyperopt has 'sell' space")
self.sell_space = self.custom_hyperopt.sell_indicator_space()
2019-11-07 22:55:14 +00:00
if HyperoptTools.has_space(self.config, 'roi'):
2019-08-01 20:57:26 +00:00
logger.debug("Hyperopt has 'roi' space")
self.roi_space = self.custom_hyperopt.roi_space()
2019-11-07 22:55:14 +00:00
if HyperoptTools.has_space(self.config, 'stoploss'):
2019-08-01 20:57:26 +00:00
logger.debug("Hyperopt has 'stoploss' space")
self.stoploss_space = self.custom_hyperopt.stoploss_space()
2019-11-07 22:55:14 +00:00
if HyperoptTools.has_space(self.config, 'trailing'):
2019-11-07 22:55:14 +00:00
logger.debug("Hyperopt has 'trailing' space")
self.trailing_space = self.custom_hyperopt.trailing_space()
2021-08-03 05:10:04 +00:00
self.dimensions = (self.buy_space + self.sell_space + self.protection_space
+ self.roi_space + self.stoploss_space + self.trailing_space)
2017-12-26 08:08:10 +00:00
def generate_optimizer(self, raw_params: List[Any], iteration=None) -> Dict:
"""
2021-08-02 19:12:10 +00:00
Used Optimize function.
Called once per epoch to optimize whatever is configured.
Keep this function as optimized as possible!
"""
backtest_start_time = datetime.now(timezone.utc)
params_dict = self._get_params_dict(self.dimensions, raw_params)
# Apply parameters
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'buy'):
self.backtesting.strategy.advise_buy = ( # type: ignore
self.custom_hyperopt.buy_strategy_generator(params_dict))
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'sell'):
self.backtesting.strategy.advise_sell = ( # type: ignore
self.custom_hyperopt.sell_strategy_generator(params_dict))
2019-01-06 09:16:30 +00:00
2021-08-03 05:10:04 +00:00
if HyperoptTools.has_space(self.config, 'protection'):
for attr_name, attr in self.backtesting.strategy.enumerate_parameters('protection'):
if attr.optimize:
# noinspection PyProtectedMember
attr.value = params_dict[attr_name]
2021-08-02 19:12:10 +00:00
if HyperoptTools.has_space(self.config, 'roi'):
self.backtesting.strategy.minimal_roi = ( # type: ignore
self.custom_hyperopt.generate_roi_table(params_dict))
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'stoploss'):
self.backtesting.strategy.stoploss = params_dict['stoploss']
2021-05-01 14:36:35 +00:00
if HyperoptTools.has_space(self.config, 'trailing'):
d = self.custom_hyperopt.generate_trailing_params(params_dict)
self.backtesting.strategy.trailing_stop = d['trailing_stop']
self.backtesting.strategy.trailing_stop_positive = d['trailing_stop_positive']
2019-11-07 22:55:14 +00:00
self.backtesting.strategy.trailing_stop_positive_offset = \
d['trailing_stop_positive_offset']
2019-11-07 22:55:14 +00:00
self.backtesting.strategy.trailing_only_offset_is_reached = \
d['trailing_only_offset_is_reached']
2019-11-07 22:55:14 +00:00
2021-05-18 18:39:55 +00:00
with self.data_pickle_file.open('rb') as f:
processed = load(f, mmap_mode='r')
bt_results = self.backtesting.backtest(
2020-01-31 21:37:05 +00:00
processed=processed,
start_date=self.min_date,
end_date=self.max_date,
2020-01-31 21:37:05 +00:00
max_open_trades=self.max_open_trades,
position_stacking=self.position_stacking,
enable_protections=self.config.get('enable_protections', False),
)
backtest_end_time = datetime.now(timezone.utc)
bt_results.update({
'backtest_start_time': int(backtest_start_time.timestamp()),
'backtest_end_time': int(backtest_end_time.timestamp()),
})
return self._get_results_dict(bt_results, self.min_date, self.max_date,
2021-05-02 09:01:26 +00:00
params_dict,
2021-02-16 09:11:33 +00:00
processed=processed)
2019-11-27 19:52:43 +00:00
def _get_results_dict(self, backtesting_results, min_date, max_date,
2021-05-02 09:01:26 +00:00
params_dict, processed: Dict[str, DataFrame]
) -> Dict[str, Any]:
2021-05-02 09:01:26 +00:00
params_details = self._get_params_details(params_dict)
strat_stats = generate_strategy_stats(
processed, self.backtesting.strategy.get_strategy_name(),
backtesting_results, min_date, max_date, market_change=0
)
results_explanation = HyperoptTools.format_results_explanation_string(
strat_stats, self.config['stake_currency'])
not_optimized = self.backtesting.strategy.get_no_optimize_params()
not_optimized = deep_merge_dicts(not_optimized, self._get_no_optimize_details())
trade_count = strat_stats['total_trades']
total_profit = strat_stats['profit_total']
# If this evaluation contains too short amount of trades to be
# interesting -- consider it as 'bad' (assigned max. loss value)
2019-05-01 12:27:58 +00:00
# in order to cast this hyperspace point away from optimization
# path. We do not want to optimize 'hodl' strategies.
loss: float = MAX_LOSS
if trade_count >= self.config['hyperopt_min_trades']:
loss = self.calculate_loss(results=backtesting_results['results'],
trade_count=trade_count,
min_date=min_date, max_date=max_date,
config=self.config, processed=processed,
backtest_stats=strat_stats)
2018-06-19 18:57:42 +00:00
return {
'loss': loss,
'params_dict': params_dict,
'params_details': params_details,
'params_not_optimized': not_optimized,
'results_metrics': strat_stats,
'results_explanation': results_explanation,
2019-08-03 16:09:42 +00:00
'total_profit': total_profit,
2018-06-19 18:57:42 +00:00
}
def get_optimizer(self, dimensions: List[Dimension], cpu_count) -> Optimizer:
2018-06-24 12:27:53 +00:00
return Optimizer(
2019-09-16 18:22:07 +00:00
dimensions,
2018-06-24 12:27:53 +00:00
base_estimator="ET",
acq_optimizer="auto",
2019-05-10 07:54:44 +00:00
n_initial_points=INITIAL_POINTS,
acq_optimizer_kwargs={'n_jobs': cpu_count},
random_state=self.random_state,
model_queue_size=SKOPT_MODEL_QUEUE_SIZE,
2018-06-24 12:27:53 +00:00
)
def run_optimizer_parallel(self, parallel, asked, i) -> List:
2018-11-20 16:43:49 +00:00
return parallel(delayed(
wrap_non_picklable_objects(self.generate_optimizer))(v, i) for v in asked)
2018-06-24 12:27:53 +00:00
def _set_random_state(self, random_state: Optional[int]) -> int:
return random_state or random.randint(1, 2**16 - 1)
def prepare_hyperopt_data(self) -> None:
data, timerange = self.backtesting.load_bt_data()
logger.info("Dataload complete. Calculating indicators")
2020-04-07 08:44:18 +00:00
2021-05-20 04:50:15 +00:00
preprocessed = self.backtesting.strategy.ohlcvdata_to_dataframe(data)
# Trim startup period from analyzed dataframe to get correct dates for output.
processed = trim_dataframes(preprocessed, timerange, self.backtesting.required_startup)
2021-05-20 04:50:15 +00:00
self.min_date, self.max_date = get_timerange(processed)
logger.info(f'Hyperopting with data from {self.min_date.strftime(DATETIME_PRINT_FORMAT)} '
f'up to {self.max_date.strftime(DATETIME_PRINT_FORMAT)} '
f'({(self.max_date - self.min_date).days} days)..')
# Store non-trimmed data - will be trimmed after signal generation.
dump(preprocessed, self.data_pickle_file)
def start(self) -> None:
self.random_state = self._set_random_state(self.config.get('hyperopt_random_state', None))
logger.info(f"Using optimizer random state: {self.random_state}")
self.hyperopt_table_header = -1
# Initialize spaces ...
self.init_spaces()
self.prepare_hyperopt_data()
# We don't need exchange instance anymore while running hyperopt
2021-02-06 09:22:59 +00:00
self.backtesting.exchange.close()
self.backtesting.exchange._api = None # type: ignore
self.backtesting.exchange._api_async = None # type: ignore
# self.backtesting.exchange = None # type: ignore
self.backtesting.pairlists = None # type: ignore
cpus = cpu_count()
logger.info(f"Found {cpus} CPU cores. Let's make them scream!")
config_jobs = self.config.get('hyperopt_jobs', -1)
logger.info(f'Number of parallel jobs set as: {config_jobs}')
2018-06-21 11:59:36 +00:00
2019-09-16 18:22:07 +00:00
self.opt = self.get_optimizer(self.dimensions, config_jobs)
2020-06-01 07:37:10 +00:00
if self.print_colorized:
colorama_init(autoreset=True)
try:
with Parallel(n_jobs=config_jobs) as parallel:
jobs = parallel._effective_n_jobs()
logger.info(f'Effective number of parallel workers used: {jobs}')
2020-03-11 21:30:36 +00:00
# Define progressbar
if self.print_colorized:
widgets = [
' [Epoch ', progressbar.Counter(), ' of ', str(self.total_epochs),
' (', progressbar.Percentage(), ')] ',
progressbar.Bar(marker=progressbar.AnimatedMarker(
fill='\N{FULL BLOCK}',
fill_wrap=Fore.GREEN + '{}' + Fore.RESET,
marker_wrap=Style.BRIGHT + '{}' + Style.RESET_ALL,
)),
' [', progressbar.ETA(), ', ', progressbar.Timer(), ']',
]
else:
widgets = [
' [Epoch ', progressbar.Counter(), ' of ', str(self.total_epochs),
' (', progressbar.Percentage(), ')] ',
progressbar.Bar(marker=progressbar.AnimatedMarker(
fill='\N{FULL BLOCK}',
)),
' [', progressbar.ETA(), ', ', progressbar.Timer(), ']',
]
with progressbar.ProgressBar(
max_value=self.total_epochs, redirect_stdout=False, redirect_stderr=False,
widgets=widgets
) as pbar:
EVALS = ceil(self.total_epochs / jobs)
for i in range(EVALS):
# Correct the number of epochs to be processed for the last
# iteration (should not exceed self.total_epochs in total)
n_rest = (i + 1) * jobs - self.total_epochs
current_jobs = jobs - n_rest if n_rest > 0 else jobs
asked = self.opt.ask(n_points=current_jobs)
f_val = self.run_optimizer_parallel(parallel, asked, i)
self.opt.tell(asked, [v['loss'] for v in f_val])
# Calculate progressbar outputs
for j, val in enumerate(f_val):
# Use human-friendly indexes here (starting from 1)
current = i * jobs + j + 1
val['current_epoch'] = current
val['is_initial_point'] = current <= INITIAL_POINTS
logger.debug(f"Optimizer epoch evaluated: {val}")
is_best = HyperoptTools.is_best_loss(val, self.current_best_loss)
# This value is assigned here and not in the optimization method
# to keep proper order in the list of results. That's because
# evaluations can take different time. Here they are aligned in the
# order they will be shown to the user.
val['is_best'] = is_best
self.print_results(val)
if is_best:
self.current_best_loss = val['loss']
2021-05-12 03:58:25 +00:00
self.current_best_epoch = val
2021-05-12 03:58:25 +00:00
self._save_result(val)
pbar.update(current)
2020-03-10 19:30:36 +00:00
except KeyboardInterrupt:
print('User interrupted..')
2020-04-28 19:56:19 +00:00
logger.info(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
f"saved to '{self.results_file}'.")
2021-05-12 03:58:25 +00:00
if self.current_best_epoch:
if self.auto_hyperopt:
HyperoptTools.try_export_params(
self.config,
self.backtesting.strategy.get_strategy_name(),
self.current_best_epoch)
2021-06-13 09:24:24 +00:00
HyperoptTools.show_epoch_details(self.current_best_epoch, self.total_epochs,
self.print_json)
else:
# This is printed when Ctrl+C is pressed quickly, before first epochs have
# a chance to be evaluated.
print("No epochs evaluated yet, no best result.")