Merge branch 'develop' into pr/Antreasgr/4838

This commit is contained in:
Matthias
2021-05-27 14:58:19 +02:00
95 changed files with 2815 additions and 1369 deletions

View File

@@ -15,7 +15,7 @@ from freqtrade.configuration import TimeRange, remove_credentials, validate_conf
from freqtrade.constants import DATETIME_PRINT_FORMAT
from freqtrade.data import history
from freqtrade.data.btanalysis import trade_list_to_dataframe
from freqtrade.data.converter import trim_dataframe
from freqtrade.data.converter import trim_dataframes
from freqtrade.data.dataprovider import DataProvider
from freqtrade.exceptions import DependencyException, OperationalException
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
@@ -63,9 +63,7 @@ class Backtesting:
self.all_results: Dict[str, Dict] = {}
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
dataprovider = DataProvider(self.config, self.exchange)
IStrategy.dp = dataprovider
self.dataprovider = DataProvider(self.config, None)
if self.config.get('strategy_list', None):
for strat in list(self.config['strategy_list']):
@@ -96,7 +94,7 @@ class Backtesting:
"PrecisionFilter not allowed for backtesting multiple strategies."
)
dataprovider.add_pairlisthandler(self.pairlists)
self.dataprovider.add_pairlisthandler(self.pairlists)
self.pairlists.refresh_pairlist()
if len(self.pairlists.whitelist) == 0:
@@ -112,15 +110,11 @@ class Backtesting:
PairLocks.timeframe = self.config['timeframe']
PairLocks.use_db = False
PairLocks.reset_locks()
if self.config.get('enable_protections', False):
self.protections = ProtectionManager(self.config)
self.wallets = Wallets(self.config, self.exchange, log=False)
# Get maximum required startup period
self.required_startup = max([strat.startup_candle_count for strat in self.strategylist])
# Load one (first) strategy
self._set_strategy(self.strategylist[0])
def __del__(self):
LoggingMixin.show_output = True
@@ -132,10 +126,17 @@ class Backtesting:
Load strategy into backtesting
"""
self.strategy: IStrategy = strategy
strategy.dp = self.dataprovider
# Set stoploss_on_exchange to false for backtesting,
# since a "perfect" stoploss-sell is assumed anyway
# And the regular "stoploss" function would not apply to that case
self.strategy.order_types['stoploss_on_exchange'] = False
if self.config.get('enable_protections', False):
conf = self.config
if hasattr(strategy, 'protections'):
conf = deepcopy(conf)
conf['protections'] = strategy.protections
self.protections = ProtectionManager(conf)
def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]:
"""
@@ -159,7 +160,7 @@ class Backtesting:
logger.info(f'Loading data from {min_date.strftime(DATETIME_PRINT_FORMAT)} '
f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} '
f'({(max_date - min_date).days} days)..')
f'({(max_date - min_date).days} days).')
# Adjust startts forward if not enough data is available
timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe),
@@ -176,6 +177,8 @@ class Backtesting:
Trade.use_db = False
PairLocks.reset_locks()
Trade.reset_trades()
self.rejected_trades = 0
self.dataprovider.clear_cache()
def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
"""
@@ -189,8 +192,9 @@ class Backtesting:
data: Dict = {}
# Create dict with data
for pair, pair_data in processed.items():
pair_data.loc[:, 'buy'] = 0 # cleanup from previous run
pair_data.loc[:, 'sell'] = 0 # cleanup from previous run
if not pair_data.empty:
pair_data.loc[:, 'buy'] = 0 # cleanup if buy_signal is exist
pair_data.loc[:, 'sell'] = 0 # cleanup if sell_signal is exist
df_analyzed = self.strategy.advise_sell(
self.strategy.advise_buy(pair_data, {'pair': pair}), {'pair': pair})[headers].copy()
@@ -214,6 +218,12 @@ class Backtesting:
"""
# Special handling if high or low hit STOP_LOSS or ROI
if sell.sell_type in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS):
if trade.stop_loss > sell_row[HIGH_IDX]:
# our stoploss was already higher than candle high,
# possibly due to a cancelled trade exit.
# sell at open price.
return sell_row[OPEN_IDX]
# Set close_rate to stoploss
return trade.stop_loss
elif sell.sell_type == (SellType.ROI):
@@ -247,10 +257,9 @@ class Backtesting:
else:
return sell_row[OPEN_IDX]
def _get_sell_trade_entry(self, dataframe: DataFrame, trade: LocalTrade,
sell_row: Tuple) -> Optional[LocalTrade]:
def _get_sell_trade_entry(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]:
sell = self.strategy.should_sell(dataframe, trade, sell_row[OPEN_IDX], # type: ignore
sell = self.strategy.should_sell(trade, sell_row[OPEN_IDX], # type: ignore
sell_row[DATE_IDX].to_pydatetime(), sell_row[BUY_IDX],
sell_row[SELL_IDX],
low=sell_row[LOW_IDX], high=sell_row[HIGH_IDX])
@@ -267,7 +276,8 @@ class Backtesting:
pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount,
rate=closerate,
time_in_force=time_in_force,
sell_reason=sell.sell_reason):
sell_reason=sell.sell_reason,
current_time=sell_row[DATE_IDX].to_pydatetime()):
return None
trade.close(closerate, show_msg=False)
@@ -287,7 +297,7 @@ class Backtesting:
# Confirm trade entry:
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
pair=pair, order_type=order_type, amount=stake_amount, rate=row[OPEN_IDX],
time_in_force=time_in_force):
time_in_force=time_in_force, current_time=row[DATE_IDX].to_pydatetime()):
return None
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
@@ -327,10 +337,18 @@ class Backtesting:
trades.append(trade1)
return trades
def trade_slot_available(self, max_open_trades: int, open_trade_count: int) -> bool:
# Always allow trades when max_open_trades is enabled.
if max_open_trades <= 0 or open_trade_count < max_open_trades:
return True
# Rejected trade
self.rejected_trades += 1
return False
def backtest(self, processed: Dict,
start_date: datetime, end_date: datetime,
max_open_trades: int = 0, position_stacking: bool = False,
enable_protections: bool = False) -> DataFrame:
enable_protections: bool = False) -> Dict[str, Any]:
"""
Implement backtesting functionality
@@ -349,6 +367,10 @@ class Backtesting:
trades: List[LocalTrade] = []
self.prepare_backtest(enable_protections)
# Update dataprovider cache
for pair, dataframe in processed.items():
self.dataprovider._set_cached_df(pair, self.timeframe, dataframe)
# Use dict of lists with data for performance
# (looping lists is a lot faster than pandas DataFrames)
data: Dict = self._get_ohlcv_as_lists(processed)
@@ -365,8 +387,9 @@ class Backtesting:
open_trade_count_start = open_trade_count
for i, pair in enumerate(data):
row_index = indexes[pair]
try:
row = data[pair][indexes[pair]]
row = data[pair][row_index]
except IndexError:
# missing Data for one pair at the end.
# Warnings for this are shown during data loading
@@ -375,16 +398,22 @@ class Backtesting:
# Waits until the time-counter reaches the start of the data for this pair.
if row[DATE_IDX] > tmp:
continue
indexes[pair] += 1
row_index += 1
self.dataprovider._set_dataframe_max_index(row_index)
indexes[pair] = row_index
# without positionstacking, we can only have one open trade per pair.
# max_open_trades must be respected
# don't open on the last row
if ((position_stacking or len(open_trades[pair]) == 0)
and (max_open_trades <= 0 or open_trade_count_start < max_open_trades)
and tmp != end_date
and row[BUY_IDX] == 1 and row[SELL_IDX] != 1
and not PairLocks.is_pair_locked(pair, row[DATE_IDX])):
if (
(position_stacking or len(open_trades[pair]) == 0)
and self.trade_slot_available(max_open_trades, open_trade_count_start)
and tmp != end_date
and row[BUY_IDX] == 1
and row[SELL_IDX] != 1
and not PairLocks.is_pair_locked(pair, row[DATE_IDX])
):
trade = self._enter_trade(pair, row)
if trade:
# TODO: hacky workaround to avoid opening > max_open_trades
@@ -398,7 +427,7 @@ class Backtesting:
for trade in open_trades[pair]:
# also check the buying candle for sell conditions.
trade_entry = self._get_sell_trade_entry(processed[pair], trade, row)
trade_entry = self._get_sell_trade_entry(trade, row)
# Sell occured
if trade_entry:
# logger.debug(f"{pair} - Backtesting sell {trade}")
@@ -417,7 +446,14 @@ class Backtesting:
trades += self.handle_left_open(open_trades, data=data)
self.wallets.update()
return trade_list_to_dataframe(trades)
results = trade_list_to_dataframe(trades)
return {
'results': results,
'config': self.strategy.config,
'locks': PairLocks.get_all_locks(),
'rejected_signals': self.rejected_trades,
'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
}
def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, Any], timerange: TimeRange):
logger.info("Running backtesting for Strategy %s", strat.get_strategy_name())
@@ -439,32 +475,32 @@ class Backtesting:
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,
startup_candles=self.required_startup)
min_date, max_date = history.get_timerange(preprocessed)
preprocessed = trim_dataframes(preprocessed, timerange, self.required_startup)
if not preprocessed:
raise OperationalException(
"No data left after adjusting for startup candles.")
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)..')
f'({(max_date - min_date).days} days).')
# Execute backtest and store results
results = self.backtest(
processed=preprocessed,
start_date=min_date.datetime,
end_date=max_date.datetime,
start_date=min_date,
end_date=max_date,
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.get_all_locks(),
'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
results.update({
'backtest_start_time': int(backtest_start_time.timestamp()),
'backtest_end_time': int(backtest_end_time.timestamp()),
}
})
self.all_results[self.strategy.get_strategy_name()] = results
return min_date, max_date
def start(self) -> None:

View File

@@ -7,20 +7,21 @@ This module contains the hyperopt logic
import logging
import random
import warnings
from datetime import datetime
from datetime import datetime, timezone
from math import ceil
from operator import itemgetter
from pathlib import Path
from typing import Any, Dict, List, Optional
import numpy as np
import progressbar
import rapidjson
from colorama import Fore, Style
from colorama import init as colorama_init
from joblib import Parallel, cpu_count, delayed, dump, load, wrap_non_picklable_objects
from pandas import DataFrame
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN
from freqtrade.data.converter import trim_dataframe
from freqtrade.data.converter import trim_dataframes
from freqtrade.data.history import get_timerange
from freqtrade.misc import file_dump_json, plural
from freqtrade.optimize.backtesting import Backtesting
@@ -29,8 +30,8 @@ from freqtrade.optimize.hyperopt_auto import HyperOptAuto
from freqtrade.optimize.hyperopt_interface import IHyperOpt # noqa: F401
from freqtrade.optimize.hyperopt_loss_interface import IHyperOptLoss # noqa: F401
from freqtrade.optimize.hyperopt_tools import HyperoptTools
from freqtrade.optimize.optimize_reports import generate_strategy_stats
from freqtrade.resolvers.hyperopt_resolver import HyperOptLossResolver, HyperOptResolver
from freqtrade.strategy import IStrategy
# Suppress scikit-learn FutureWarnings from skopt
@@ -64,6 +65,13 @@ class Hyperopt:
custom_hyperopt: IHyperOpt
def __init__(self, config: Dict[str, Any]) -> None:
self.buy_space: List[Dimension] = []
self.sell_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
self.backtesting = Backtesting(self.config)
@@ -72,15 +80,15 @@ class Hyperopt:
self.custom_hyperopt = HyperOptAuto(self.config)
else:
self.custom_hyperopt = HyperOptResolver.load_hyperopt(self.config)
self.backtesting._set_strategy(self.backtesting.strategylist[0])
self.custom_hyperopt.strategy = self.backtesting.strategy
self.custom_hyperoptloss = HyperOptLossResolver.load_hyperoptloss(self.config)
self.calculate_loss = self.custom_hyperoptloss.hyperopt_loss_function
time_now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
strategy = str(self.config['strategy'])
self.results_file = (self.config['user_data_dir'] /
'hyperopt_results' /
f'strategy_{strategy}_hyperopt_results_{time_now}.pickle')
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)
@@ -90,9 +98,7 @@ class Hyperopt:
self.clean_hyperopt()
self.num_epochs_saved = 0
# Previous evaluations
self.epochs: List = []
self.current_best_epoch: Optional[Dict[str, Any]] = None
# Populate functions here (hasattr is slow so should not be run during "regular" operations)
if hasattr(self.custom_hyperopt, 'populate_indicators'):
@@ -113,7 +119,7 @@ class Hyperopt:
self.max_open_trades = 0
self.position_stacking = self.config.get('position_stacking', False)
if self.has_space('sell'):
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'] = {}
@@ -139,9 +145,7 @@ class Hyperopt:
logger.info(f"Removing `{p}`.")
p.unlink()
def _get_params_dict(self, raw_params: List[Any]) -> Dict:
dimensions: List[Dimension] = self.dimensions
def _get_params_dict(self, dimensions: List[Dimension], raw_params: List[Any]) -> Dict:
# Ensure the number of dimensions match
# the number of parameters in the list.
@@ -152,21 +156,30 @@ class Hyperopt:
# and the values are taken from the list of parameters.
return {d.name: v for d, v in zip(dimensions, raw_params)}
def _save_results(self) -> None:
def _save_result(self, epoch: Dict) -> None:
"""
Save hyperopt results to file
Store one line per epoch.
While not a valid json object - this allows appending easily.
:param epoch: result dictionary for this epoch.
"""
num_epochs = len(self.epochs)
if num_epochs > self.num_epochs_saved:
logger.debug(f"Saving {num_epochs} {plural(num_epochs, 'epoch')}.")
dump(self.epochs, self.results_file)
self.num_epochs_saved = num_epochs
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 default_parser(x):
if isinstance(x, np.integer):
return int(x)
return str(x)
with self.results_file.open('a') as f:
rapidjson.dump(epoch, f, default=default_parser,
number_mode=rapidjson.NM_NATIVE | rapidjson.NM_NAN)
f.write("\n")
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:
"""
@@ -174,18 +187,16 @@ class Hyperopt:
"""
result: Dict = {}
if self.has_space('buy'):
result['buy'] = {p.name: params.get(p.name)
for p in self.hyperopt_space('buy')}
if self.has_space('sell'):
result['sell'] = {p.name: params.get(p.name)
for p in self.hyperopt_space('sell')}
if self.has_space('roi'):
result['roi'] = self.custom_hyperopt.generate_roi_table(params)
if self.has_space('stoploss'):
result['stoploss'] = {p.name: params.get(p.name)
for p in self.hyperopt_space('stoploss')}
if self.has_space('trailing'):
if HyperoptTools.has_space(self.config, 'buy'):
result['buy'] = {p.name: params.get(p.name) for p in self.buy_space}
if HyperoptTools.has_space(self.config, 'sell'):
result['sell'] = {p.name: params.get(p.name) for p in self.sell_space}
if HyperoptTools.has_space(self.config, 'roi'):
result['roi'] = {str(k): v for k, v in
self.custom_hyperopt.generate_roi_table(params).items()}
if HyperoptTools.has_space(self.config, 'stoploss'):
result['stoploss'] = {p.name: params.get(p.name) for p in self.stoploss_space}
if HyperoptTools.has_space(self.config, 'trailing'):
result['trailing'] = self.custom_hyperopt.generate_trailing_params(params)
return result
@@ -207,71 +218,58 @@ class Hyperopt:
)
self.hyperopt_table_header = 2
def has_space(self, space: str) -> bool:
def init_spaces(self):
"""
Tell if the space value is contained in the configuration
Assign the dimensions in the hyperoptimization space.
"""
# The 'trailing' space is not included in the 'default' set of spaces
if space == 'trailing':
return any(s in self.config['spaces'] for s in [space, 'all'])
else:
return any(s in self.config['spaces'] for s in [space, 'all', 'default'])
def hyperopt_space(self, space: Optional[str] = None) -> List[Dimension]:
"""
Return the dimensions in the hyperoptimization space.
:param space: Defines hyperspace to return dimensions for.
If None, then the self.has_space() will be used to return dimensions
for all hyperspaces used.
"""
spaces: List[Dimension] = []
if space == 'buy' or (space is None and self.has_space('buy')):
if HyperoptTools.has_space(self.config, 'buy'):
logger.debug("Hyperopt has 'buy' space")
spaces += self.custom_hyperopt.indicator_space()
self.buy_space = self.custom_hyperopt.indicator_space()
if space == 'sell' or (space is None and self.has_space('sell')):
if HyperoptTools.has_space(self.config, 'sell'):
logger.debug("Hyperopt has 'sell' space")
spaces += self.custom_hyperopt.sell_indicator_space()
self.sell_space = self.custom_hyperopt.sell_indicator_space()
if space == 'roi' or (space is None and self.has_space('roi')):
if HyperoptTools.has_space(self.config, 'roi'):
logger.debug("Hyperopt has 'roi' space")
spaces += self.custom_hyperopt.roi_space()
self.roi_space = self.custom_hyperopt.roi_space()
if space == 'stoploss' or (space is None and self.has_space('stoploss')):
if HyperoptTools.has_space(self.config, 'stoploss'):
logger.debug("Hyperopt has 'stoploss' space")
spaces += self.custom_hyperopt.stoploss_space()
self.stoploss_space = self.custom_hyperopt.stoploss_space()
if space == 'trailing' or (space is None and self.has_space('trailing')):
if HyperoptTools.has_space(self.config, 'trailing'):
logger.debug("Hyperopt has 'trailing' space")
spaces += self.custom_hyperopt.trailing_space()
return spaces
self.trailing_space = self.custom_hyperopt.trailing_space()
self.dimensions = (self.buy_space + self.sell_space + self.roi_space +
self.stoploss_space + self.trailing_space)
def generate_optimizer(self, raw_params: List[Any], iteration=None) -> Dict:
"""
Used Optimize function. Called once per epoch to optimize whatever is configured.
Keep this function as optimized as possible!
"""
params_dict = self._get_params_dict(raw_params)
params_details = self._get_params_details(params_dict)
backtest_start_time = datetime.now(timezone.utc)
params_dict = self._get_params_dict(self.dimensions, raw_params)
if self.has_space('roi'):
# Apply parameters
if HyperoptTools.has_space(self.config, 'roi'):
self.backtesting.strategy.minimal_roi = ( # type: ignore
self.custom_hyperopt.generate_roi_table(params_dict))
if self.has_space('buy'):
if HyperoptTools.has_space(self.config, 'buy'):
self.backtesting.strategy.advise_buy = ( # type: ignore
self.custom_hyperopt.buy_strategy_generator(params_dict))
if self.has_space('sell'):
if HyperoptTools.has_space(self.config, 'sell'):
self.backtesting.strategy.advise_sell = ( # type: ignore
self.custom_hyperopt.sell_strategy_generator(params_dict))
if self.has_space('stoploss'):
if HyperoptTools.has_space(self.config, 'stoploss'):
self.backtesting.strategy.stoploss = params_dict['stoploss']
if self.has_space('trailing'):
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']
@@ -280,30 +278,42 @@ class Hyperopt:
self.backtesting.strategy.trailing_only_offset_is_reached = \
d['trailing_only_offset_is_reached']
processed = load(self.data_pickle_file)
min_date, max_date = get_timerange(processed)
backtesting_results = self.backtesting.backtest(
with self.data_pickle_file.open('rb') as f:
processed = load(f, mmap_mode='r')
bt_results = self.backtesting.backtest(
processed=processed,
start_date=min_date.datetime,
end_date=max_date.datetime,
start_date=self.min_date,
end_date=self.max_date,
max_open_trades=self.max_open_trades,
position_stacking=self.position_stacking,
enable_protections=self.config.get('enable_protections', False),
)
return self._get_results_dict(backtesting_results, min_date, max_date,
params_dict, params_details,
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,
params_dict,
processed=processed)
def _get_results_dict(self, backtesting_results, min_date, max_date,
params_dict, params_details, processed: Dict[str, DataFrame]):
results_metrics = self._calculate_results_metrics(backtesting_results)
results_explanation = self._format_results_explanation_string(results_metrics)
params_dict, processed: Dict[str, DataFrame]
) -> Dict[str, Any]:
params_details = self._get_params_details(params_dict)
trade_count = results_metrics['trade_count']
total_profit = results_metrics['total_profit']
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_params_dict()
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)
@@ -311,50 +321,20 @@ class Hyperopt:
# 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, trade_count=trade_count,
min_date=min_date.datetime, max_date=max_date.datetime,
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)
return {
'loss': loss,
'params_dict': params_dict,
'params_details': params_details,
'results_metrics': results_metrics,
'params_not_optimized': not_optimized,
'results_metrics': strat_stats,
'results_explanation': results_explanation,
'total_profit': total_profit,
}
def _calculate_results_metrics(self, backtesting_results: DataFrame) -> Dict:
wins = len(backtesting_results[backtesting_results['profit_ratio'] > 0])
draws = len(backtesting_results[backtesting_results['profit_ratio'] == 0])
losses = len(backtesting_results[backtesting_results['profit_ratio'] < 0])
return {
'trade_count': len(backtesting_results.index),
'wins': wins,
'draws': draws,
'losses': losses,
'winsdrawslosses': f"{wins:>4} {draws:>4} {losses:>4}",
'avg_profit': backtesting_results['profit_ratio'].mean() * 100.0,
'median_profit': backtesting_results['profit_ratio'].median() * 100.0,
'total_profit': backtesting_results['profit_abs'].sum(),
'profit': backtesting_results['profit_ratio'].sum() * 100.0,
'duration': backtesting_results['trade_duration'].mean(),
}
def _format_results_explanation_string(self, results_metrics: Dict) -> str:
"""
Return the formatted results explanation in a string
"""
stake_cur = self.config['stake_currency']
return (f"{results_metrics['trade_count']:6d} trades. "
f"{results_metrics['wins']}/{results_metrics['draws']}"
f"/{results_metrics['losses']} Wins/Draws/Losses. "
f"Avg profit {results_metrics['avg_profit']: 6.2f}%. "
f"Median profit {results_metrics['median_profit']: 6.2f}%. "
f"Total profit {results_metrics['total_profit']: 11.8f} {stake_cur} "
f"({results_metrics['profit']: 7.2f}%). "
f"Avg duration {results_metrics['duration']:5.1f} min."
)
def get_optimizer(self, dimensions: List[Dimension], cpu_count) -> Optimizer:
return Optimizer(
dimensions,
@@ -373,25 +353,31 @@ class Hyperopt:
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")
preprocessed = self.backtesting.strategy.ohlcvdata_to_dataframe(data)
# Trim startup period from analyzed dataframe
processed = trim_dataframes(preprocessed, timerange, self.backtesting.required_startup)
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)..')
dump(processed, 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
data, timerange = self.backtesting.load_bt_data()
logger.info("Dataload complete. Calculating indicators")
preprocessed = self.backtesting.strategy.ohlcvdata_to_dataframe(data)
# Initialize spaces ...
self.init_spaces()
# Trim startup period from analyzed dataframe
for pair, df in preprocessed.items():
preprocessed[pair] = trim_dataframe(df, timerange,
startup_candles=self.backtesting.required_startup)
min_date, max_date = get_timerange(preprocessed)
logger.info(f'Hyperopting 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)..')
dump(preprocessed, self.data_pickle_file)
self.prepare_hyperopt_data()
# We don't need exchange instance anymore while running hyperopt
self.backtesting.exchange.close()
@@ -399,15 +385,12 @@ class Hyperopt:
self.backtesting.exchange._api_async = None # type: ignore
# self.backtesting.exchange = None # type: ignore
self.backtesting.pairlists = None # type: ignore
self.backtesting.strategy.dp = None # type: ignore
IStrategy.dp = 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}')
self.dimensions: List[Dimension] = self.hyperopt_space()
self.opt = self.get_optimizer(self.dimensions, config_jobs)
if self.print_colorized:
@@ -473,25 +456,21 @@ class Hyperopt:
if is_best:
self.current_best_loss = val['loss']
self.epochs.append(val)
self.current_best_epoch = val
# Save results after each best epoch and every 100 epochs
if is_best or current % 100 == 0:
self._save_results()
self._save_result(val)
pbar.update(current)
except KeyboardInterrupt:
print('User interrupted..')
self._save_results()
logger.info(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} "
f"saved to '{self.results_file}'.")
if self.epochs:
sorted_epochs = sorted(self.epochs, key=itemgetter('loss'))
best_epoch = sorted_epochs[0]
HyperoptTools.print_epoch_details(best_epoch, self.total_epochs, self.print_json)
if self.current_best_epoch:
HyperoptTools.print_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.

View File

@@ -3,17 +3,15 @@ import io
import logging
from collections import OrderedDict
from pathlib import Path
from pprint import pformat
from typing import Dict, List
from typing import Any, Dict, List
import rapidjson
import tabulate
from colorama import Fore, Style
from joblib import load
from pandas import isna, json_normalize
from freqtrade.exceptions import OperationalException
from freqtrade.misc import round_dict
from freqtrade.misc import round_coin_value, round_dict
logger = logging.getLogger(__name__)
@@ -21,13 +19,38 @@ logger = logging.getLogger(__name__)
class HyperoptTools():
@staticmethod
def has_space(config: Dict[str, Any], space: str) -> bool:
"""
Tell if the space value is contained in the configuration
"""
# The 'trailing' space is not included in the 'default' set of spaces
if space == 'trailing':
return any(s in config['spaces'] for s in [space, 'all'])
else:
return any(s in config['spaces'] for s in [space, 'all', 'default'])
@staticmethod
def _read_results_pickle(results_file: Path) -> List:
"""
Read hyperopt results from pickle file
LEGACY method - new files are written as json and cannot be read with this method.
"""
from joblib import load
logger.info(f"Reading pickled epochs from '{results_file}'")
data = load(results_file)
return data
@staticmethod
def _read_results(results_file: Path) -> List:
"""
Read hyperopt results from file
"""
logger.info("Reading epochs from '%s'", results_file)
data = load(results_file)
import rapidjson
logger.info(f"Reading epochs from '{results_file}'")
with results_file.open('r') as f:
data = [rapidjson.loads(line) for line in f]
return data
@staticmethod
@@ -37,7 +60,10 @@ class HyperoptTools():
"""
epochs: List = []
if results_file.is_file() and results_file.stat().st_size > 0:
epochs = HyperoptTools._read_results(results_file)
if results_file.suffix == '.pickle':
epochs = HyperoptTools._read_results_pickle(results_file)
else:
epochs = HyperoptTools._read_results(results_file)
# Detection of some old format, without 'is_best' field saved
if epochs[0].get('is_best') is None:
raise OperationalException(
@@ -53,6 +79,7 @@ class HyperoptTools():
Display details of the hyperopt result
"""
params = results.get('params_details', {})
non_optimized = results.get('params_not_optimized', {})
# Default header string
if header_str is None:
@@ -69,8 +96,10 @@ class HyperoptTools():
print(rapidjson.dumps(result_dict, default=str, number_mode=rapidjson.NM_NATIVE))
else:
HyperoptTools._params_pretty_print(params, 'buy', "Buy hyperspace params:")
HyperoptTools._params_pretty_print(params, 'sell', "Sell hyperspace params:")
HyperoptTools._params_pretty_print(params, 'buy', "Buy hyperspace params:",
non_optimized)
HyperoptTools._params_pretty_print(params, 'sell', "Sell hyperspace params:",
non_optimized)
HyperoptTools._params_pretty_print(params, 'roi', "ROI table:")
HyperoptTools._params_pretty_print(params, 'stoploss', "Stoploss:")
HyperoptTools._params_pretty_print(params, 'trailing', "Trailing stop:")
@@ -96,12 +125,12 @@ class HyperoptTools():
result_dict.update(space_params)
@staticmethod
def _params_pretty_print(params, space: str, header: str) -> None:
if space in params:
def _params_pretty_print(params, space: str, header: str, non_optimized={}) -> None:
if space in params or space in non_optimized:
space_params = HyperoptTools._space_params(params, space, 5)
params_result = f"\n# {header}\n"
result = f"\n# {header}\n"
if space == 'stoploss':
params_result += f"stoploss = {space_params.get('stoploss')}"
result += f"stoploss = {space_params.get('stoploss')}"
elif space == 'roi':
# TODO: get rid of OrderedDict when support for python 3.6 will be
# dropped (dicts keep the order as the language feature)
@@ -110,28 +139,64 @@ class HyperoptTools():
(str(k), v) for k, v in space_params.items()
),
default=str, indent=4, number_mode=rapidjson.NM_NATIVE)
params_result += f"minimal_roi = {minimal_roi_result}"
result += f"minimal_roi = {minimal_roi_result}"
elif space == 'trailing':
for k, v in space_params.items():
params_result += f'{k} = {v}\n'
result += f'{k} = {v}\n'
else:
params_result += f"{space}_params = {pformat(space_params, indent=4)}"
params_result = params_result.replace("}", "\n}").replace("{", "{\n ")
no_params = HyperoptTools._space_params(non_optimized, space, 5)
params_result = params_result.replace("\n", "\n ")
print(params_result)
result += f"{space}_params = {HyperoptTools._pprint(space_params, no_params)}"
result = result.replace("\n", "\n ")
print(result)
@staticmethod
def _space_params(params, space: str, r: int = None) -> Dict:
d = params[space]
# Round floats to `r` digits after the decimal point if requested
return round_dict(d, r) if r else d
d = params.get(space)
if d:
# Round floats to `r` digits after the decimal point if requested
return round_dict(d, r) if r else d
return {}
@staticmethod
def _pprint(params, non_optimized, indent: int = 4):
"""
Pretty-print hyperopt results (based on 2 dicts - with add. comment)
"""
p = params.copy()
p.update(non_optimized)
result = '{\n'
for k, param in p.items():
result += " " * indent + f'"{k}": '
result += f'"{param}",' if isinstance(param, str) else f'{param},'
if k in non_optimized:
result += " # value loaded from strategy"
result += "\n"
result += '}'
return result
@staticmethod
def is_best_loss(results, current_best_loss: float) -> bool:
return results['loss'] < current_best_loss
return bool(results['loss'] < current_best_loss)
@staticmethod
def format_results_explanation_string(results_metrics: Dict, stake_currency: str) -> str:
"""
Return the formatted results explanation in a string
"""
return (f"{results_metrics['total_trades']:6d} trades. "
f"{results_metrics['wins']}/{results_metrics['draws']}"
f"/{results_metrics['losses']} Wins/Draws/Losses. "
f"Avg profit {results_metrics['profit_mean'] * 100: 6.2f}%. "
f"Median profit {results_metrics['profit_median'] * 100: 6.2f}%. "
f"Total profit {results_metrics['profit_total_abs']: 11.8f} {stake_currency} "
f"({results_metrics['profit_total'] * 100: 7.2f}%). "
f"Avg duration {results_metrics['holding_avg']} min."
)
@staticmethod
def _format_explanation_string(results, total_epochs) -> str:
@@ -156,12 +221,27 @@ class HyperoptTools():
if 'results_metrics.winsdrawslosses' not in trials.columns:
# Ensure compatibility with older versions of hyperopt results
trials['results_metrics.winsdrawslosses'] = 'N/A'
legacy_mode = True
if 'results_metrics.total_trades' in trials:
legacy_mode = False
# New mode, using backtest result for metrics
trials['results_metrics.winsdrawslosses'] = trials.apply(
lambda x: f"{x['results_metrics.wins']} {x['results_metrics.draws']:>4} "
f"{x['results_metrics.losses']:>4}", axis=1)
trials = trials[['Best', 'current_epoch', 'results_metrics.total_trades',
'results_metrics.winsdrawslosses',
'results_metrics.profit_mean', 'results_metrics.profit_total_abs',
'results_metrics.profit_total', 'results_metrics.holding_avg',
'loss', 'is_initial_point', 'is_best']]
else:
# Legacy mode
trials = trials[['Best', 'current_epoch', 'results_metrics.trade_count',
'results_metrics.winsdrawslosses',
'results_metrics.avg_profit', 'results_metrics.total_profit',
'results_metrics.profit', 'results_metrics.duration',
'loss', 'is_initial_point', 'is_best']]
trials = trials[['Best', 'current_epoch', 'results_metrics.trade_count',
'results_metrics.winsdrawslosses',
'results_metrics.avg_profit', 'results_metrics.total_profit',
'results_metrics.profit', 'results_metrics.duration',
'loss', 'is_initial_point', 'is_best']]
trials.columns = ['Best', 'Epoch', 'Trades', ' Win Draw Loss', 'Avg profit',
'Total profit', 'Profit', 'Avg duration', 'Objective',
'is_initial_point', 'is_best']
@@ -171,26 +251,28 @@ class HyperoptTools():
trials.loc[trials['is_initial_point'] & trials['is_best'], 'Best'] = '* Best'
trials.loc[trials['Total profit'] > 0, 'is_profit'] = True
trials['Trades'] = trials['Trades'].astype(str)
perc_multi = 1 if legacy_mode else 100
trials['Epoch'] = trials['Epoch'].apply(
lambda x: '{}/{}'.format(str(x).rjust(len(str(total_epochs)), ' '), total_epochs)
)
trials['Avg profit'] = trials['Avg profit'].apply(
lambda x: '{:,.2f}%'.format(x).rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
lambda x: f'{x * perc_multi:,.2f}%'.rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
)
trials['Avg duration'] = trials['Avg duration'].apply(
lambda x: '{:,.1f} m'.format(x).rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
lambda x: f'{x:,.1f} m'.rjust(7, ' ') if isinstance(x, float) else f"{x}"
if not isna(x) else "--".rjust(7, ' ')
)
trials['Objective'] = trials['Objective'].apply(
lambda x: '{:,.5f}'.format(x).rjust(8, ' ') if x != 100000 else "N/A".rjust(8, ' ')
lambda x: f'{x:,.5f}'.rjust(8, ' ') if x != 100000 else "N/A".rjust(8, ' ')
)
stake_currency = config['stake_currency']
trials['Profit'] = trials.apply(
lambda x: '{:,.8f} {} {}'.format(
x['Total profit'], config['stake_currency'],
'({:,.2f}%)'.format(x['Profit']).rjust(10, ' ')
).rjust(25+len(config['stake_currency']))
if x['Total profit'] != 0.0 else '--'.rjust(25+len(config['stake_currency'])),
lambda x: '{} {}'.format(
round_coin_value(x['Total profit'], stake_currency),
'({:,.2f}%)'.format(x['Profit'] * perc_multi).rjust(10, ' ')
).rjust(25+len(stake_currency))
if x['Total profit'] != 0.0 else '--'.rjust(25+len(stake_currency)),
axis=1
)
trials = trials.drop(columns=['Total profit'])
@@ -251,11 +333,21 @@ class HyperoptTools():
trials['Best'] = ''
trials['Stake currency'] = config['stake_currency']
base_metrics = ['Best', 'current_epoch', 'results_metrics.trade_count',
'results_metrics.avg_profit', 'results_metrics.median_profit',
'results_metrics.total_profit',
'Stake currency', 'results_metrics.profit', 'results_metrics.duration',
'loss', 'is_initial_point', 'is_best']
if 'results_metrics.total_trades' in trials:
base_metrics = ['Best', 'current_epoch', 'results_metrics.total_trades',
'results_metrics.profit_mean', 'results_metrics.profit_median',
'results_metrics.profit_total',
'Stake currency',
'results_metrics.profit_total_abs', 'results_metrics.holding_avg',
'loss', 'is_initial_point', 'is_best']
perc_multi = 100
else:
perc_multi = 1
base_metrics = ['Best', 'current_epoch', 'results_metrics.trade_count',
'results_metrics.avg_profit', 'results_metrics.median_profit',
'results_metrics.total_profit',
'Stake currency', 'results_metrics.profit', 'results_metrics.duration',
'loss', 'is_initial_point', 'is_best']
param_metrics = [("params_dict."+param) for param in results[0]['params_dict'].keys()]
trials = trials[base_metrics + param_metrics]
@@ -272,21 +364,24 @@ class HyperoptTools():
trials.loc[trials['Total profit'] > 0, 'is_profit'] = True
trials['Epoch'] = trials['Epoch'].astype(str)
trials['Trades'] = trials['Trades'].astype(str)
trials['Median profit'] = trials['Median profit'] * perc_multi
trials['Total profit'] = trials['Total profit'].apply(
lambda x: '{:,.8f}'.format(x) if x != 0.0 else ""
lambda x: f'{x:,.8f}' if x != 0.0 else ""
)
trials['Profit'] = trials['Profit'].apply(
lambda x: '{:,.2f}'.format(x) if not isna(x) else ""
lambda x: f'{x:,.2f}' if not isna(x) else ""
)
trials['Avg profit'] = trials['Avg profit'].apply(
lambda x: '{:,.2f}%'.format(x) if not isna(x) else ""
)
trials['Avg duration'] = trials['Avg duration'].apply(
lambda x: '{:,.1f} m'.format(x) if not isna(x) else ""
lambda x: f'{x * perc_multi:,.2f}%' if not isna(x) else ""
)
if perc_multi == 1:
trials['Avg duration'] = trials['Avg duration'].apply(
lambda x: f'{x:,.1f} m' if isinstance(
x, float) else f"{x.total_seconds() // 60:,.1f} m" if not isna(x) else ""
)
trials['Objective'] = trials['Objective'].apply(
lambda x: '{:,.5f}'.format(x) if x != 100000 else ""
lambda x: f'{x:,.5f}' if x != 100000 else ""
)
trials = trials.drop(columns=['is_initial_point', 'is_best', 'is_profit'])

View File

@@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Union
from arrow import Arrow
from numpy import int64
from pandas import DataFrame
from tabulate import tabulate
@@ -44,7 +43,7 @@ def _get_line_floatfmt(stake_currency: str) -> List[str]:
Generate floatformat (goes in line with _generate_result_line())
"""
return ['s', 'd', '.2f', '.2f', f'.{decimals_per_coin(stake_currency)}f',
'.2f', 'd', 'd', 'd', 'd']
'.2f', 'd', 's', 's']
def _get_line_header(first_column: str, stake_currency: str) -> List[str]:
@@ -53,7 +52,17 @@ def _get_line_header(first_column: str, stake_currency: str) -> List[str]:
"""
return [first_column, 'Buys', 'Avg Profit %', 'Cum Profit %',
f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration',
'Wins', 'Draws', 'Losses']
'Win Draw Loss Win%']
def _generate_wins_draws_losses(wins, draws, losses):
if wins > 0 and losses == 0:
wl_ratio = '100'
elif wins == 0:
wl_ratio = '0'
else:
wl_ratio = f'{100.0 / (wins + draws + losses) * wins:.1f}' if losses > 0 else '100'
return f'{wins:>4} {draws:>4} {losses:>4} {wl_ratio:>4}'
def _generate_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict:
@@ -153,7 +162,7 @@ def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List
return tabular_data
def generate_strategy_metrics(all_results: Dict) -> List[Dict]:
def generate_strategy_comparison(all_results: Dict) -> List[Dict]:
"""
Generate summary per strategy
:param all_results: Dict of <Strategyname: DataFrame> containing results for all strategies
@@ -165,6 +174,17 @@ def generate_strategy_metrics(all_results: Dict) -> List[Dict]:
tabular_data.append(_generate_result_line(
results['results'], results['config']['dry_run_wallet'], strategy)
)
try:
max_drawdown_per, _, _, _, _ = calculate_max_drawdown(results['results'],
value_col='profit_ratio')
max_drawdown_abs, _, _, _, _ = calculate_max_drawdown(results['results'],
value_col='profit_abs')
except ValueError:
max_drawdown_per = 0
max_drawdown_abs = 0
tabular_data[-1]['max_drawdown_per'] = round(max_drawdown_per * 100, 2)
tabular_data[-1]['max_drawdown_abs'] = \
round_coin_value(max_drawdown_abs, results['config']['stake_currency'], False)
return tabular_data
@@ -194,7 +214,40 @@ def generate_edge_table(results: dict) -> str:
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") # type: ignore
def generate_trading_stats(results: DataFrame) -> Dict[str, Any]:
""" Generate overall trade statistics """
if len(results) == 0:
return {
'wins': 0,
'losses': 0,
'draws': 0,
'holding_avg': timedelta(),
'winner_holding_avg': timedelta(),
'loser_holding_avg': timedelta(),
}
winning_trades = results.loc[results['profit_ratio'] > 0]
draw_trades = results.loc[results['profit_ratio'] == 0]
losing_trades = results.loc[results['profit_ratio'] < 0]
zero_duration_trades = len(results.loc[(results['trade_duration'] == 0) &
(results['sell_reason'] == 'trailing_stop_loss')])
return {
'wins': len(winning_trades),
'losses': len(losing_trades),
'draws': len(draw_trades),
'holding_avg': (timedelta(minutes=round(results['trade_duration'].mean()))
if not results.empty else timedelta()),
'winner_holding_avg': (timedelta(minutes=round(winning_trades['trade_duration'].mean()))
if not winning_trades.empty else timedelta()),
'loser_holding_avg': (timedelta(minutes=round(losing_trades['trade_duration'].mean()))
if not losing_trades.empty else timedelta()),
'zero_duration_trades': zero_duration_trades,
}
def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
""" Generate daily statistics """
if len(results) == 0:
return {
'backtest_best_day': 0,
@@ -204,8 +257,6 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
'winning_days': 0,
'draw_days': 0,
'losing_days': 0,
'winner_holding_avg': timedelta(),
'loser_holding_avg': timedelta(),
}
daily_profit_rel = results.resample('1d', on='close_date')['profit_ratio'].sum()
daily_profit = results.resample('1d', on='close_date')['profit_abs'].sum().round(10)
@@ -217,9 +268,6 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
draw_days = sum(daily_profit == 0)
losing_days = sum(daily_profit < 0)
winning_trades = results.loc[results['profit_ratio'] > 0]
losing_trades = results.loc[results['profit_ratio'] < 0]
return {
'backtest_best_day': best_rel,
'backtest_worst_day': worst_rel,
@@ -228,16 +276,152 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
'winning_days': winning_days,
'draw_days': draw_days,
'losing_days': losing_days,
'winner_holding_avg': (timedelta(minutes=round(winning_trades['trade_duration'].mean()))
if not winning_trades.empty else timedelta()),
'loser_holding_avg': (timedelta(minutes=round(losing_trades['trade_duration'].mean()))
if not losing_trades.empty else timedelta()),
}
def generate_strategy_stats(btdata: Dict[str, DataFrame],
strategy: str,
content: Dict[str, Any],
min_date: datetime, max_date: datetime,
market_change: float
) -> Dict[str, Any]:
"""
:param btdata: Backtest data
:param strategy: Strategy name
:param content: Backtest result data in the format:
{'results: results, 'config: config}}.
:param min_date: Backtest start date
:param max_date: Backtest end date
:param market_change: float indicating the market change
:return: Dictionary containing results per strategy and a stratgy summary.
"""
results: Dict[str, DataFrame] = content['results']
if not isinstance(results, DataFrame):
return {}
config = content['config']
max_open_trades = min(config['max_open_trades'], len(btdata.keys()))
starting_balance = config['dry_run_wallet']
stake_currency = config['stake_currency']
pair_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
starting_balance=starting_balance,
results=results, skip_nan=False)
sell_reason_stats = generate_sell_reason_stats(max_open_trades=max_open_trades,
results=results)
left_open_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
starting_balance=starting_balance,
results=results.loc[results['is_open']],
skip_nan=True)
daily_stats = generate_daily_stats(results)
trade_stats = generate_trading_stats(results)
best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'],
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'],
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
results['open_timestamp'] = results['open_date'].astype(int64) // 1e6
results['close_timestamp'] = results['close_date'].astype(int64) // 1e6
backtest_days = (max_date - min_date).days
strat_stats = {
'trades': results.to_dict(orient='records'),
'locks': [lock.to_json() for lock in content['locks']],
'best_pair': best_pair,
'worst_pair': worst_pair,
'results_per_pair': pair_results,
'sell_reason_summary': sell_reason_stats,
'left_open_trades': left_open_results,
'total_trades': len(results),
'total_volume': float(results['stake_amount'].sum()),
'avg_stake_amount': results['stake_amount'].mean() if len(results) > 0 else 0,
'profit_mean': results['profit_ratio'].mean() if len(results) > 0 else 0,
'profit_median': results['profit_ratio'].median() if len(results) > 0 else 0,
'profit_total': results['profit_abs'].sum() / starting_balance,
'profit_total_abs': results['profit_abs'].sum(),
'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT),
'backtest_start_ts': int(min_date.timestamp() * 1000),
'backtest_end': max_date.strftime(DATETIME_PRINT_FORMAT),
'backtest_end_ts': int(max_date.timestamp() * 1000),
'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,
'market_change': market_change,
'pairlist': list(btdata.keys()),
'stake_amount': config['stake_amount'],
'stake_currency': config['stake_currency'],
'stake_currency_decimals': decimals_per_coin(config['stake_currency']),
'starting_balance': starting_balance,
'dry_run_wallet': starting_balance,
'final_balance': content['final_balance'],
'rejected_signals': content['rejected_signals'],
'max_open_trades': max_open_trades,
'max_open_trades_setting': (config['max_open_trades']
if config['max_open_trades'] != float('inf') else -1),
'timeframe': config['timeframe'],
'timerange': config.get('timerange', ''),
'enable_protections': config.get('enable_protections', False),
'strategy_name': strategy,
# Parameters relevant for backtesting
'stoploss': config['stoploss'],
'trailing_stop': config.get('trailing_stop', False),
'trailing_stop_positive': config.get('trailing_stop_positive'),
'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0),
'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False),
'use_custom_stoploss': config.get('use_custom_stoploss', False),
'minimal_roi': config['minimal_roi'],
'use_sell_signal': config['ask_strategy']['use_sell_signal'],
'sell_profit_only': config['ask_strategy']['sell_profit_only'],
'sell_profit_offset': config['ask_strategy']['sell_profit_offset'],
'ignore_roi_if_buy_signal': config['ask_strategy']['ignore_roi_if_buy_signal'],
**daily_stats,
**trade_stats
}
try:
max_drawdown, _, _, _, _ = calculate_max_drawdown(
results, value_col='profit_ratio')
drawdown_abs, drawdown_start, drawdown_end, high_val, low_val = calculate_max_drawdown(
results, value_col='profit_abs')
strat_stats.update({
'max_drawdown': max_drawdown,
'max_drawdown_abs': drawdown_abs,
'drawdown_start': drawdown_start.strftime(DATETIME_PRINT_FORMAT),
'drawdown_start_ts': drawdown_start.timestamp() * 1000,
'drawdown_end': drawdown_end.strftime(DATETIME_PRINT_FORMAT),
'drawdown_end_ts': drawdown_end.timestamp() * 1000,
'max_drawdown_low': low_val,
'max_drawdown_high': high_val,
})
csum_min, csum_max = calculate_csum(results, starting_balance)
strat_stats.update({
'csum_min': csum_min,
'csum_max': csum_max
})
except ValueError:
strat_stats.update({
'max_drawdown': 0.0,
'max_drawdown_abs': 0.0,
'max_drawdown_low': 0.0,
'max_drawdown_high': 0.0,
'drawdown_start': datetime(1970, 1, 1, tzinfo=timezone.utc),
'drawdown_start_ts': 0,
'drawdown_end': datetime(1970, 1, 1, tzinfo=timezone.utc),
'drawdown_end_ts': 0,
'csum_min': 0,
'csum_max': 0
})
return strat_stats
def generate_backtest_stats(btdata: Dict[str, DataFrame],
all_results: Dict[str, Dict[str, Union[DataFrame, Dict]]],
min_date: Arrow, max_date: Arrow
min_date: datetime, max_date: datetime
) -> Dict[str, Any]:
"""
:param btdata: Backtest data
@@ -245,132 +429,17 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame],
{ Strategy: {'results: results, 'config: config}}.
:param min_date: Backtest start date
:param max_date: Backtest end date
:return:
Dictionary containing results per strategy and a stratgy summary.
:return: Dictionary containing results per strategy and a stratgy summary.
"""
result: Dict[str, Any] = {'strategy': {}}
market_change = calculate_market_change(btdata, 'close')
for strategy, content in all_results.items():
results: Dict[str, DataFrame] = content['results']
if not isinstance(results, DataFrame):
continue
config = content['config']
max_open_trades = min(config['max_open_trades'], len(btdata.keys()))
starting_balance = config['dry_run_wallet']
stake_currency = config['stake_currency']
pair_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
starting_balance=starting_balance,
results=results, skip_nan=False)
sell_reason_stats = generate_sell_reason_stats(max_open_trades=max_open_trades,
results=results)
left_open_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
starting_balance=starting_balance,
results=results.loc[results['is_open']],
skip_nan=True)
daily_stats = generate_daily_stats(results)
best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'],
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'],
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
results['open_timestamp'] = results['open_date'].astype(int64) // 1e6
results['close_timestamp'] = results['close_date'].astype(int64) // 1e6
backtest_days = (max_date - min_date).days
strat_stats = {
'trades': results.to_dict(orient='records'),
'locks': [lock.to_json() for lock in content['locks']],
'best_pair': best_pair,
'worst_pair': worst_pair,
'results_per_pair': pair_results,
'sell_reason_summary': sell_reason_stats,
'left_open_trades': left_open_results,
'total_trades': len(results),
'total_volume': float(results['stake_amount'].sum()),
'avg_stake_amount': results['stake_amount'].mean() if len(results) > 0 else 0,
'profit_mean': results['profit_ratio'].mean() if len(results) > 0 else 0,
'profit_total': results['profit_abs'].sum() / starting_balance,
'profit_total_abs': results['profit_abs'].sum(),
'backtest_start': min_date.datetime,
'backtest_start_ts': min_date.int_timestamp * 1000,
'backtest_end': max_date.datetime,
'backtest_end_ts': max_date.int_timestamp * 1000,
'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,
'market_change': market_change,
'pairlist': list(btdata.keys()),
'stake_amount': config['stake_amount'],
'stake_currency': config['stake_currency'],
'stake_currency_decimals': decimals_per_coin(config['stake_currency']),
'starting_balance': starting_balance,
'dry_run_wallet': starting_balance,
'final_balance': content['final_balance'],
'max_open_trades': max_open_trades,
'max_open_trades_setting': (config['max_open_trades']
if config['max_open_trades'] != float('inf') else -1),
'timeframe': config['timeframe'],
'timerange': config.get('timerange', ''),
'enable_protections': config.get('enable_protections', False),
'strategy_name': strategy,
# Parameters relevant for backtesting
'stoploss': config['stoploss'],
'trailing_stop': config.get('trailing_stop', False),
'trailing_stop_positive': config.get('trailing_stop_positive'),
'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0),
'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False),
'use_custom_stoploss': config.get('use_custom_stoploss', False),
'minimal_roi': config['minimal_roi'],
'use_sell_signal': config['ask_strategy']['use_sell_signal'],
'sell_profit_only': config['ask_strategy']['sell_profit_only'],
'sell_profit_offset': config['ask_strategy']['sell_profit_offset'],
'ignore_roi_if_buy_signal': config['ask_strategy']['ignore_roi_if_buy_signal'],
**daily_stats,
}
strat_stats = generate_strategy_stats(btdata, strategy, content,
min_date, max_date, market_change=market_change)
result['strategy'][strategy] = strat_stats
try:
max_drawdown, _, _, _, _ = calculate_max_drawdown(
results, value_col='profit_ratio')
drawdown_abs, drawdown_start, drawdown_end, high_val, low_val = calculate_max_drawdown(
results, value_col='profit_abs')
strat_stats.update({
'max_drawdown': max_drawdown,
'max_drawdown_abs': drawdown_abs,
'drawdown_start': drawdown_start,
'drawdown_start_ts': drawdown_start.timestamp() * 1000,
'drawdown_end': drawdown_end,
'drawdown_end_ts': drawdown_end.timestamp() * 1000,
'max_drawdown_low': low_val,
'max_drawdown_high': high_val,
})
csum_min, csum_max = calculate_csum(results, starting_balance)
strat_stats.update({
'csum_min': csum_min,
'csum_max': csum_max
})
except ValueError:
strat_stats.update({
'max_drawdown': 0.0,
'max_drawdown_abs': 0.0,
'max_drawdown_low': 0.0,
'max_drawdown_high': 0.0,
'drawdown_start': datetime(1970, 1, 1, tzinfo=timezone.utc),
'drawdown_start_ts': 0,
'drawdown_end': datetime(1970, 1, 1, tzinfo=timezone.utc),
'drawdown_end_ts': 0,
'csum_min': 0,
'csum_max': 0
})
strategy_results = generate_strategy_metrics(all_results=all_results)
strategy_results = generate_strategy_comparison(all_results=all_results)
result['strategy_comparison'] = strategy_results
@@ -393,7 +462,8 @@ def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: st
floatfmt = _get_line_floatfmt(stake_currency)
output = [[
t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
t['profit_total_pct'], t['duration_avg'], t['wins'], t['draws'], t['losses']
t['profit_total_pct'], t['duration_avg'],
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses'])
] for t in pair_results]
# Ignore type as floatfmt does allow tuples but mypy does not know that
return tabulate(output, headers=headers,
@@ -410,9 +480,7 @@ def text_table_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_curren
headers = [
'Sell Reason',
'Sells',
'Wins',
'Draws',
'Losses',
'Win Draws Loss Win%',
'Avg Profit %',
'Cum Profit %',
f'Tot Profit {stake_currency}',
@@ -420,7 +488,8 @@ def text_table_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_curren
]
output = [[
t['sell_reason'], t['trades'], t['wins'], t['draws'], t['losses'],
t['sell_reason'], t['trades'],
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses']),
t['profit_mean_pct'], t['profit_sum_pct'],
round_coin_value(t['profit_total_abs'], stake_currency, False),
t['profit_total_pct'],
@@ -438,11 +507,22 @@ def text_table_strategy(strategy_results, stake_currency: str) -> str:
"""
floatfmt = _get_line_floatfmt(stake_currency)
headers = _get_line_header('Strategy', stake_currency)
# _get_line_header() is also used for per-pair summary. Per-pair drawdown is mostly useless
# therefore we slip this column in only for strategy summary here.
headers.append('Drawdown')
# Align drawdown string on the center two space separator.
drawdown = [f'{t["max_drawdown_per"]:.2f}' for t in strategy_results]
dd_pad_abs = max([len(t['max_drawdown_abs']) for t in strategy_results])
dd_pad_per = max([len(dd) for dd in drawdown])
drawdown = [f'{t["max_drawdown_abs"]:>{dd_pad_abs}} {stake_currency} {dd:>{dd_pad_per}}%'
for t, dd in zip(strategy_results, drawdown)]
output = [[
t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'],
t['profit_total_pct'], t['duration_avg'], t['wins'], t['draws'], t['losses']
] for t in strategy_results]
t['profit_total_pct'], t['duration_avg'],
_generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), drawdown]
for t, drawdown in zip(strategy_results, drawdown)]
# Ignore type as floatfmt does allow tuples but mypy does not know that
return tabulate(output, headers=headers,
floatfmt=floatfmt, tablefmt="orgtbl", stralign="right")
@@ -452,9 +532,21 @@ def text_table_add_metrics(strat_results: Dict) -> str:
if len(strat_results['trades']) > 0:
best_trade = max(strat_results['trades'], key=lambda x: x['profit_ratio'])
worst_trade = min(strat_results['trades'], key=lambda x: x['profit_ratio'])
# Newly added fields should be ignored if they are missing in strat_results. hyperopt-show
# command stores these results and newer version of freqtrade must be able to handle old
# results with missing new fields.
zero_duration_trades = '--'
if 'zero_duration_trades' in strat_results:
zero_duration_trades_per = \
100.0 / strat_results['total_trades'] * strat_results['zero_duration_trades']
zero_duration_trades = f'{zero_duration_trades_per:.2f}% ' \
f'({strat_results["zero_duration_trades"]})'
metrics = [
('Backtesting from', strat_results['backtest_start'].strftime(DATETIME_PRINT_FORMAT)),
('Backtesting to', strat_results['backtest_end'].strftime(DATETIME_PRINT_FORMAT)),
('Backtesting from', strat_results['backtest_start']),
('Backtesting to', strat_results['backtest_end']),
('Max open trades', strat_results['max_open_trades']),
('', ''), # Empty line to improve readability
('Total trades', strat_results['total_trades']),
@@ -464,13 +556,12 @@ def text_table_add_metrics(strat_results: Dict) -> str:
strat_results['stake_currency'])),
('Absolute profit ', round_coin_value(strat_results['profit_total_abs'],
strat_results['stake_currency'])),
('Total profit %', f"{round(strat_results['profit_total'] * 100, 2)}%"),
('Total profit %', f"{round(strat_results['profit_total'] * 100, 2):}%"),
('Trades per day', strat_results['trades_per_day']),
('Avg. stake amount', round_coin_value(strat_results['avg_stake_amount'],
strat_results['stake_currency'])),
('Total trade volume', round_coin_value(strat_results['total_volume'],
strat_results['stake_currency'])),
('', ''), # Empty line to improve readability
('Best Pair', f"{strat_results['best_pair']['key']} "
f"{round(strat_results['best_pair']['profit_sum_pct'], 2)}%"),
@@ -488,6 +579,8 @@ def text_table_add_metrics(strat_results: Dict) -> str:
f"{strat_results['draw_days']} / {strat_results['losing_days']}"),
('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"),
('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"),
('Zero Duration Trades', zero_duration_trades),
('Rejected Buy signals', strat_results.get('rejected_signals', 'N/A')),
('', ''), # Empty line to improve readability
('Min balance', round_coin_value(strat_results['csum_min'],
@@ -502,8 +595,8 @@ def text_table_add_metrics(strat_results: Dict) -> str:
strat_results['stake_currency'])),
('Drawdown low', round_coin_value(strat_results['max_drawdown_low'],
strat_results['stake_currency'])),
('Drawdown Start', strat_results['drawdown_start'].strftime(DATETIME_PRINT_FORMAT)),
('Drawdown End', strat_results['drawdown_end'].strftime(DATETIME_PRINT_FORMAT)),
('Drawdown Start', strat_results['drawdown_start']),
('Drawdown End', strat_results['drawdown_end']),
('Market change', f"{round(strat_results['market_change'] * 100, 2)}%"),
]
@@ -522,37 +615,43 @@ def text_table_add_metrics(strat_results: Dict) -> str:
return message
def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: str):
"""
Print results for one strategy
"""
# Print results
print(f"Result for strategy {strategy}")
table = text_table_bt_results(results['results_per_pair'], stake_currency=stake_currency)
if isinstance(table, str):
print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '='))
print(table)
table = text_table_sell_reason(sell_reason_stats=results['sell_reason_summary'],
stake_currency=stake_currency)
if isinstance(table, str) and len(table) > 0:
print(' SELL REASON STATS '.center(len(table.splitlines()[0]), '='))
print(table)
table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency)
if isinstance(table, str) and len(table) > 0:
print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '='))
print(table)
table = text_table_add_metrics(results)
if isinstance(table, str) and len(table) > 0:
print(' SUMMARY METRICS '.center(len(table.splitlines()[0]), '='))
print(table)
if isinstance(table, str) and len(table) > 0:
print('=' * len(table.splitlines()[0]))
print()
def show_backtest_results(config: Dict, backtest_stats: Dict):
stake_currency = config['stake_currency']
for strategy, results in backtest_stats['strategy'].items():
# Print results
print(f"Result for strategy {strategy}")
table = text_table_bt_results(results['results_per_pair'], stake_currency=stake_currency)
if isinstance(table, str):
print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '='))
print(table)
table = text_table_sell_reason(sell_reason_stats=results['sell_reason_summary'],
stake_currency=stake_currency)
if isinstance(table, str) and len(table) > 0:
print(' SELL REASON STATS '.center(len(table.splitlines()[0]), '='))
print(table)
table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency)
if isinstance(table, str) and len(table) > 0:
print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '='))
print(table)
table = text_table_add_metrics(results)
if isinstance(table, str) and len(table) > 0:
print(' SUMMARY METRICS '.center(len(table.splitlines()[0]), '='))
print(table)
if isinstance(table, str) and len(table) > 0:
print('=' * len(table.splitlines()[0]))
print()
show_backtest_result(strategy, results, stake_currency)
if len(backtest_stats['strategy']) > 1:
# Print Strategy summary table