From 801ab4acc9a40a355c7987052161ac1805fdf1e5 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 12 May 2024 17:20:36 +0200 Subject: [PATCH] ruff format: optimize --- freqtrade/optimize/backtest_caching.py | 18 +- freqtrade/optimize/backtesting.py | 802 +++++++++++------- freqtrade/optimize/base_analysis.py | 8 +- freqtrade/optimize/bt_progress.py | 5 +- freqtrade/optimize/edge_cli.py | 14 +- .../optimize/optimize_reports/bt_output.py | 528 +++++++----- .../optimize/optimize_reports/bt_storage.py | 37 +- .../optimize_reports/optimize_reports.py | 616 +++++++------- freqtrade/optimize/space/decimalspace.py | 14 +- 9 files changed, 1194 insertions(+), 848 deletions(-) diff --git a/freqtrade/optimize/backtest_caching.py b/freqtrade/optimize/backtest_caching.py index f34bbffef..2f9c151ad 100644 --- a/freqtrade/optimize/backtest_caching.py +++ b/freqtrade/optimize/backtest_caching.py @@ -17,19 +17,23 @@ def get_strategy_run_id(strategy) -> str: config = deepcopy(strategy.config) # Options that have no impact on results of individual backtest. - not_important_keys = ('strategy_list', 'original_config', 'telegram', 'api_server') + not_important_keys = ("strategy_list", "original_config", "telegram", "api_server") for k in not_important_keys: if k in config: del config[k] # Explicitly allow NaN values (e.g. max_open_trades). # as it does not matter for getting the hash. - digest.update(rapidjson.dumps(config, default=str, - number_mode=rapidjson.NM_NAN).encode('utf-8')) + digest.update( + rapidjson.dumps(config, default=str, number_mode=rapidjson.NM_NAN).encode("utf-8") + ) # Include _ft_params_from_file - so changing parameter files cause cache eviction - digest.update(rapidjson.dumps( - strategy._ft_params_from_file, default=str, number_mode=rapidjson.NM_NAN).encode('utf-8')) - with Path(strategy.__file__).open('rb') as fp: + digest.update( + rapidjson.dumps( + strategy._ft_params_from_file, default=str, number_mode=rapidjson.NM_NAN + ).encode("utf-8") + ) + with Path(strategy.__file__).open("rb") as fp: digest.update(fp.read()) return digest.hexdigest().lower() @@ -37,4 +41,4 @@ def get_strategy_run_id(strategy) -> str: def get_backtest_metadata_filename(filename: Union[Path, str]) -> Path: """Return metadata filename for specified backtest results file.""" filename = Path(filename) - return filename.parent / Path(f'{filename.stem}.meta{filename.suffix}') + return filename.parent / Path(f"{filename.stem}.meta{filename.suffix}") diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 39c7aa8bc..95ad1b821 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -3,6 +3,7 @@ """ This module contains the backtesting logic """ + import logging from collections import defaultdict from copy import deepcopy @@ -83,8 +84,19 @@ EXIT_TAG_IDX = 10 # Every change to this headers list must evaluate further usages of the resulting tuple # and eventually change the constants for indexes at the top -HEADERS = ['date', 'open', 'high', 'low', 'close', 'enter_long', 'exit_long', - 'enter_short', 'exit_short', 'enter_tag', 'exit_tag'] +HEADERS = [ + "date", + "open", + "high", + "low", + "close", + "enter_long", + "exit_long", + "enter_short", + "exit_short", + "enter_tag", + "exit_tag", +] class Backtesting: @@ -97,14 +109,13 @@ class Backtesting: """ def __init__(self, config: Config, exchange: Optional[Exchange] = None) -> None: - LoggingMixin.show_output = False self.config = config self.results: BacktestResultType = get_BacktestResultType_default() self.trade_id_counter: int = 0 self.order_id_counter: int = 0 - config['dry_run'] = True + config["dry_run"] = True self.run_ids: Dict[str, str] = {} self.strategylist: List[IStrategy] = [] self.all_results: Dict[str, Dict] = {} @@ -112,20 +123,22 @@ class Backtesting: self.rejected_dict: Dict[str, List] = {} self.rejected_df: Dict[str, Dict] = {} - self._exchange_name = self.config['exchange']['name'] + self._exchange_name = self.config["exchange"]["name"] if not exchange: exchange = ExchangeResolver.load_exchange(self.config, load_leverage_tiers=True) self.exchange = exchange self.dataprovider = DataProvider(self.config, self.exchange) - if self.config.get('strategy_list'): - if self.config.get('freqai', {}).get('enabled', False): - logger.warning("Using --strategy-list with FreqAI REQUIRES all strategies " - "to have identical feature_engineering_* functions.") - for strat in list(self.config['strategy_list']): + if self.config.get("strategy_list"): + if self.config.get("freqai", {}).get("enabled", False): + logger.warning( + "Using --strategy-list with FreqAI REQUIRES all strategies " + "to have identical feature_engineering_* functions." + ) + for strat in list(self.config["strategy_list"]): stratconf = deepcopy(self.config) - stratconf['strategy'] = strat + stratconf["strategy"] = strat self.strategylist.append(StrategyResolver.load_strategy(stratconf)) validate_config_consistency(stratconf) @@ -135,9 +148,11 @@ class Backtesting: validate_config_consistency(self.config) if "timeframe" not in self.config: - raise OperationalException("Timeframe needs to be set in either " - "configuration or as cli argument `--timeframe 5m`") - self.timeframe = str(self.config.get('timeframe')) + raise OperationalException( + "Timeframe needs to be set in either " + "configuration or as cli argument `--timeframe 5m`" + ) + self.timeframe = str(self.config.get("timeframe")) self.timeframe_secs = timeframe_to_seconds(self.timeframe) self.timeframe_min = self.timeframe_secs // 60 self.timeframe_td = timedelta(seconds=self.timeframe_secs) @@ -152,57 +167,60 @@ class Backtesting: if len(self.pairlists.whitelist) == 0: raise OperationalException("No pair in whitelist.") - if config.get('fee', None) is not None: - self.fee = config['fee'] + if config.get("fee", None) is not None: + self.fee = config["fee"] logger.info(f"Using fee {self.fee:.4%} from config.") else: fees = [ self.exchange.get_fee( symbol=self.pairlists.whitelist[0], taker_or_maker=mt, # type: ignore - ) - for mt in ('taker', 'maker') + ) + for mt in ("taker", "maker") ] self.fee = max(fee for fee in fees if fee is not None) logger.info(f"Using fee {self.fee:.4%} - worst case fee from exchange (lowest tier).") self.precision_mode = self.exchange.precisionMode - if self.config.get('freqai_backtest_live_models', False): + if self.config.get("freqai_backtest_live_models", False): from freqtrade.freqai.utils import get_timerange_backtest_live_models - self.config['timerange'] = get_timerange_backtest_live_models(self.config) + + self.config["timerange"] = get_timerange_backtest_live_models(self.config) self.timerange = TimeRange.parse_timerange( - None if self.config.get('timerange') is None else str(self.config.get('timerange'))) + None if self.config.get("timerange") is None else str(self.config.get("timerange")) + ) # Get maximum required startup period self.required_startup = max([strat.startup_candle_count for strat in self.strategylist]) self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe) # Add maximum startup candle count to configuration for informative pairs support - self.config['startup_candle_count'] = self.required_startup + self.config["startup_candle_count"] = self.required_startup - if self.config.get('freqai', {}).get('enabled', False): + if self.config.get("freqai", {}).get("enabled", False): # For FreqAI, increase the required_startup to includes the training data # This value should NOT be written to startup_candle_count self.required_startup = self.dataprovider.get_required_startup(self.timeframe) - self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT) + self.trading_mode: TradingMode = config.get("trading_mode", TradingMode.SPOT) # strategies which define "can_short=True" will fail to load in Spot mode. self._can_short = self.trading_mode != TradingMode.SPOT - self._position_stacking: bool = self.config.get('position_stacking', False) - self.enable_protections: bool = self.config.get('enable_protections', False) + self._position_stacking: bool = self.config.get("position_stacking", False) + self.enable_protections: bool = self.config.get("enable_protections", False) migrate_data(config, self.exchange) self.init_backtest() def _validate_pairlists_for_backtesting(self): - if 'VolumePairList' in self.pairlists.name_list: - raise OperationalException("VolumePairList not allowed for backtesting. " - "Please use StaticPairList instead.") - if 'PerformanceFilter' in self.pairlists.name_list: + if "VolumePairList" in self.pairlists.name_list: + raise OperationalException( + "VolumePairList not allowed for backtesting. " "Please use StaticPairList instead." + ) + if "PerformanceFilter" in self.pairlists.name_list: raise OperationalException("PerformanceFilter not allowed for backtesting.") - if len(self.strategylist) > 1 and 'PrecisionFilter' in self.pairlists.name_list: + if len(self.strategylist) > 1 and "PrecisionFilter" in self.pairlists.name_list: raise OperationalException( "PrecisionFilter not allowed for backtesting multiple strategies." ) @@ -214,13 +232,14 @@ class Backtesting: def init_backtest_detail(self) -> None: # Load detail timeframe if specified - self.timeframe_detail = str(self.config.get('timeframe_detail', '')) + self.timeframe_detail = str(self.config.get("timeframe_detail", "")) if self.timeframe_detail: timeframe_detail_secs = timeframe_to_seconds(self.timeframe_detail) self.timeframe_detail_td = timedelta(seconds=timeframe_detail_secs) if self.timeframe_secs <= timeframe_detail_secs: raise OperationalException( - "Detail timeframe must be smaller than strategy timeframe.") + "Detail timeframe must be smaller than strategy timeframe." + ) else: self.timeframe_detail_td = timedelta(seconds=0) @@ -228,7 +247,6 @@ class Backtesting: self.futures_data: Dict[str, DataFrame] = {} def init_backtest(self): - self.prepare_backtest(False) self.wallets = Wallets(self.config, self.exchange, is_backtest=True) @@ -247,18 +265,18 @@ class Backtesting: # Set stoploss_on_exchange to false for backtesting, # since a "perfect" stoploss-exit is assumed anyway # And the regular "stoploss" function would not apply to that case - self.strategy.order_types['stoploss_on_exchange'] = False + self.strategy.order_types["stoploss_on_exchange"] = False # Update can_short flag self._can_short = self.trading_mode != TradingMode.SPOT and strategy.can_short self.strategy.ft_bot_start() def _load_protections(self, strategy: IStrategy): - if self.config.get('enable_protections', False): + if self.config.get("enable_protections", False): conf = self.config - if hasattr(strategy, 'protections'): + if hasattr(strategy, "protections"): conf = deepcopy(conf) - conf['protections'] = strategy.protections + conf["protections"] = strategy.protections self.protections = ProtectionManager(self.config, strategy.protections) def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]: @@ -269,25 +287,28 @@ class Backtesting: self.progress.init_step(BacktestState.DATALOAD, 1) data = history.load_data( - datadir=self.config['datadir'], + datadir=self.config["datadir"], pairs=self.pairlists.whitelist, timeframe=self.timeframe, timerange=self.timerange, startup_candles=self.required_startup, fail_without_data=True, - data_format=self.config['dataformat_ohlcv'], - candle_type=self.config.get('candle_type_def', CandleType.SPOT) + data_format=self.config["dataformat_ohlcv"], + candle_type=self.config.get("candle_type_def", CandleType.SPOT), ) min_date, max_date = history.get_timerange(data) - 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).') + 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)." + ) # Adjust startts forward if not enough data is available - self.timerange.adjust_start_if_necessary(timeframe_to_seconds(self.timeframe), - self.required_startup, min_date) + self.timerange.adjust_start_if_necessary( + timeframe_to_seconds(self.timeframe), self.required_startup, min_date + ) self.progress.set_new_value(1) return data, self.timerange @@ -298,44 +319,44 @@ class Backtesting: """ if self.timeframe_detail: self.detail_data = history.load_data( - datadir=self.config['datadir'], + datadir=self.config["datadir"], pairs=self.pairlists.whitelist, timeframe=self.timeframe_detail, timerange=self.timerange, startup_candles=0, fail_without_data=True, - data_format=self.config['dataformat_ohlcv'], - candle_type=self.config.get('candle_type_def', CandleType.SPOT) + data_format=self.config["dataformat_ohlcv"], + candle_type=self.config.get("candle_type_def", CandleType.SPOT), ) else: self.detail_data = {} if self.trading_mode == TradingMode.FUTURES: - self.funding_fee_timeframe: str = self.exchange.get_option('funding_fee_timeframe') + self.funding_fee_timeframe: str = self.exchange.get_option("funding_fee_timeframe") self.funding_fee_timeframe_secs: int = timeframe_to_seconds(self.funding_fee_timeframe) - mark_timeframe: str = self.exchange.get_option('mark_ohlcv_timeframe') + mark_timeframe: str = self.exchange.get_option("mark_ohlcv_timeframe") # Load additional futures data. funding_rates_dict = history.load_data( - datadir=self.config['datadir'], + datadir=self.config["datadir"], pairs=self.pairlists.whitelist, timeframe=self.funding_fee_timeframe, timerange=self.timerange, startup_candles=0, fail_without_data=True, - data_format=self.config['dataformat_ohlcv'], - candle_type=CandleType.FUNDING_RATE + data_format=self.config["dataformat_ohlcv"], + candle_type=CandleType.FUNDING_RATE, ) # For simplicity, assign to CandleType.Mark (might contain index candles!) mark_rates_dict = history.load_data( - datadir=self.config['datadir'], + datadir=self.config["datadir"], pairs=self.pairlists.whitelist, timeframe=mark_timeframe, timerange=self.timerange, startup_candles=0, fail_without_data=True, - data_format=self.config['dataformat_ohlcv'], - candle_type=CandleType.from_string(self.exchange.get_option("mark_ohlcv_price")) + data_format=self.config["dataformat_ohlcv"], + candle_type=CandleType.from_string(self.exchange.get_option("mark_ohlcv_price")), ) # Combine data to avoid combining the data per trade. unavailable_pairs = [] @@ -347,13 +368,14 @@ class Backtesting: self.futures_data[pair] = self.exchange.combine_funding_and_mark( funding_rates=funding_rates_dict[pair], mark_rates=mark_rates_dict[pair], - futures_funding_rate=self.config.get('futures_funding_rate', None), + futures_funding_rate=self.config.get("futures_funding_rate", None), ) if unavailable_pairs: raise OperationalException( f"Pairs {', '.join(unavailable_pairs)} got no leverage tiers available. " - "It is therefore impossible to backtest with this pair at the moment.") + "It is therefore impossible to backtest with this pair at the moment." + ) else: self.futures_data = {} @@ -408,15 +430,17 @@ class Backtesting: if not pair_data.empty: # Cleanup from prior runs - pair_data.drop(HEADERS[5:] + ['buy', 'sell'], axis=1, errors='ignore') - df_analyzed = self.strategy.ft_advise_signals(pair_data, {'pair': pair}) + pair_data.drop(HEADERS[5:] + ["buy", "sell"], axis=1, errors="ignore") + df_analyzed = self.strategy.ft_advise_signals(pair_data, {"pair": pair}) # Update dataprovider cache self.dataprovider._set_cached_df( - pair, self.timeframe, df_analyzed, self.config['candle_type_def']) + pair, self.timeframe, df_analyzed, self.config["candle_type_def"] + ) # Trim startup period from analyzed dataframe df_analyzed = processed[pair] = pair_data = trim_dataframe( - df_analyzed, self.timerange, startup_candles=self.required_startup) + df_analyzed, self.timerange, startup_candles=self.required_startup + ) # Create a copy of the dataframe before shifting, that way the entry signal/tag # remains on the correct candle for callbacks. @@ -425,10 +449,13 @@ class Backtesting: # To avoid using data from future, we use entry/exit signals shifted # from the previous candle for col in HEADERS[5:]: - tag_col = col in ('enter_tag', 'exit_tag') + tag_col = col in ("enter_tag", "exit_tag") if col in df_analyzed.columns: - df_analyzed[col] = df_analyzed.loc[:, col].replace( - [nan], [0 if not tag_col else None]).shift(1) + df_analyzed[col] = ( + df_analyzed.loc[:, col] + .replace([nan], [0 if not tag_col else None]) + .shift(1) + ) elif not df_analyzed.empty: df_analyzed[col] = 0 if not tag_col else None @@ -439,22 +466,27 @@ class Backtesting: data[pair] = df_analyzed[HEADERS].values.tolist() if not df_analyzed.empty else [] return data - def _get_close_rate(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple, - trade_dur: int) -> float: + def _get_close_rate( + self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple, trade_dur: int + ) -> float: """ Get close rate for backtesting result """ # Special handling if high or low hit STOP_LOSS or ROI if exit.exit_type in ( - ExitType.STOP_LOSS, ExitType.TRAILING_STOP_LOSS, ExitType.LIQUIDATION): + ExitType.STOP_LOSS, + ExitType.TRAILING_STOP_LOSS, + ExitType.LIQUIDATION, + ): return self._get_close_rate_for_stoploss(row, trade, exit, trade_dur) elif exit.exit_type == (ExitType.ROI): return self._get_close_rate_for_roi(row, trade, exit, trade_dur) else: return row[OPEN_IDX] - def _get_close_rate_for_stoploss(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple, - trade_dur: int) -> float: + def _get_close_rate_for_stoploss( + self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple, trade_dur: int + ) -> float: # our stoploss was already lower than candle high, # possibly due to a cancelled trade exit. # exit at open price. @@ -478,19 +510,23 @@ class Backtesting: # immediately going down to stop price. if exit.exit_type == ExitType.TRAILING_STOP_LOSS and trade_dur == 0: if ( - not self.strategy.use_custom_stoploss and self.strategy.trailing_stop + not self.strategy.use_custom_stoploss + and self.strategy.trailing_stop and self.strategy.trailing_only_offset_is_reached and self.strategy.trailing_stop_positive_offset is not None and self.strategy.trailing_stop_positive ): # Worst case: price reaches stop_positive_offset and dives down. - stop_rate = (row[OPEN_IDX] * - (1 + side_1 * abs(self.strategy.trailing_stop_positive_offset) - - side_1 * abs(self.strategy.trailing_stop_positive / leverage))) + stop_rate = row[OPEN_IDX] * ( + 1 + + side_1 * abs(self.strategy.trailing_stop_positive_offset) + - side_1 * abs(self.strategy.trailing_stop_positive / leverage) + ) else: # Worst case: price ticks tiny bit above open and dives down. - stop_rate = row[OPEN_IDX] * (1 - side_1 * abs( - (trade.stop_loss_pct or 0.0) / leverage)) + stop_rate = row[OPEN_IDX] * ( + 1 - side_1 * abs((trade.stop_loss_pct or 0.0) / leverage) + ) # Limit lower-end to candle low to avoid exits below the low. # This still remains "worst case" - but "worst realistic case". @@ -502,8 +538,9 @@ class Backtesting: # Set close_rate to stoploss return stoploss_value - def _get_close_rate_for_roi(self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple, - trade_dur: int) -> float: + def _get_close_rate_for_roi( + self, row: Tuple, trade: LocalTrade, exit: ExitCheckTuple, trade_dur: int + ) -> float: is_short = trade.is_short or False leverage = trade.leverage or 1.0 side_1 = -1 if is_short else 1 @@ -523,14 +560,17 @@ class Backtesting: is_new_roi = row[OPEN_IDX] < close_rate else: is_new_roi = row[OPEN_IDX] > close_rate - if (trade_dur > 0 and trade_dur == roi_entry - and roi_entry % self.timeframe_min == 0 - and is_new_roi): + if ( + trade_dur > 0 + and trade_dur == roi_entry + and roi_entry % self.timeframe_min == 0 + and is_new_roi + ): # new ROI entry came into effect. # use Open rate if open_rate > calculated exit rate return row[OPEN_IDX] - if (trade_dur == 0 and ( + if trade_dur == 0 and ( ( is_short # Red candle (for longs) @@ -538,15 +578,14 @@ class Backtesting: and trade.open_rate > row[OPEN_IDX] # trade-open above open_rate and close_rate < row[CLOSE_IDX] # closes below close ) - or - ( + or ( not is_short # green candle (for shorts) and row[OPEN_IDX] > row[CLOSE_IDX] # green candle and trade.open_rate < row[OPEN_IDX] # trade-open below open_rate and close_rate > row[CLOSE_IDX] # closes above close ) - )): + ): # ROI on opening candles with custom pricing can only # trigger if the entry was at Open or lower wick. # details: https: // github.com/freqtrade/freqtrade/issues/6261 @@ -563,7 +602,7 @@ class Backtesting: return row[OPEN_IDX] def _get_adjust_trade_entry_for_candle( - self, trade: LocalTrade, row: Tuple, current_time: datetime + self, trade: LocalTrade, row: Tuple, current_time: datetime ) -> LocalTrade: current_rate: float = row[OPEN_IDX] current_profit = trade.calc_profit_ratio(current_rate) @@ -572,11 +611,15 @@ class Backtesting: stake_available = self.wallets.get_available_stake_amount() stake_amount, order_tag = self.strategy._adjust_trade_position_internal( trade=trade, # type: ignore[arg-type] - current_time=current_time, current_rate=current_rate, - current_profit=current_profit, min_stake=min_stake, + current_time=current_time, + current_rate=current_rate, + current_profit=current_profit, + min_stake=min_stake, max_stake=min(max_stake, stake_available), - current_entry_rate=current_rate, current_exit_rate=current_rate, - current_entry_profit=current_profit, current_exit_profit=current_profit + current_entry_rate=current_rate, + current_exit_rate=current_rate, + current_entry_profit=current_profit, + current_exit_profit=current_profit, ) # Check if we should increase our position @@ -584,21 +627,33 @@ class Backtesting: check_adjust_entry = True if self.strategy.max_entry_position_adjustment > -1: entry_count = trade.nr_of_successful_entries - check_adjust_entry = (entry_count <= self.strategy.max_entry_position_adjustment) + check_adjust_entry = entry_count <= self.strategy.max_entry_position_adjustment if check_adjust_entry: pos_trade = self._enter_trade( - trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade, - entry_tag1=order_tag) + trade.pair, + row, + "short" if trade.is_short else "long", + stake_amount, + trade, + entry_tag1=order_tag, + ) if pos_trade is not None: self.wallets.update() return pos_trade if stake_amount is not None and stake_amount < 0.0: amount = amount_to_contract_precision( - abs(float(FtPrecise(stake_amount) * FtPrecise(trade.amount) - / FtPrecise(trade.stake_amount))), + abs( + float( + FtPrecise(stake_amount) + * FtPrecise(trade.amount) + / FtPrecise(trade.stake_amount) + ) + ), trade.amount_precision, - self.precision_mode, trade.contract_size) + self.precision_mode, + trade.contract_size, + ) if amount == 0.0: return trade remaining = (trade.amount - amount) * current_rate @@ -616,17 +671,23 @@ class Backtesting: return trade def _get_order_filled(self, rate: float, row: Tuple) -> bool: - """ Rate is within candle, therefore filled""" + """Rate is within candle, therefore filled""" return row[LOW_IDX] <= rate <= row[HIGH_IDX] def _call_adjust_stop(self, current_date: datetime, trade: LocalTrade, current_rate: float): profit = trade.calc_profit_ratio(current_rate) - self.strategy.ft_stoploss_adjust(current_rate, trade, # type: ignore - current_date, profit, 0, after_fill=True) + self.strategy.ft_stoploss_adjust( + current_rate, + trade, # type: ignore + current_date, + profit, + 0, + after_fill=True, + ) def _try_close_open_order( - self, order: Optional[Order], trade: LocalTrade, current_date: datetime, - row: Tuple) -> bool: + self, order: Optional[Order], trade: LocalTrade, current_date: datetime, row: Tuple + ) -> bool: """ Check if an order is open and if it should've filled. :return: True if the order filled. @@ -634,30 +695,33 @@ class Backtesting: if order and self._get_order_filled(order.ft_price, row): order.close_bt_order(current_date, trade) self._run_funding_fees(trade, current_date, force=True) - strategy_safe_wrapper( - self.strategy.order_filled, - default_retval=None)( - pair=trade.pair, trade=trade, # type: ignore[arg-type] - order=order, current_time=current_date) + strategy_safe_wrapper(self.strategy.order_filled, default_retval=None)( + pair=trade.pair, + trade=trade, # type: ignore[arg-type] + order=order, + current_time=current_date, + ) if not (order.ft_order_side == trade.exit_side and order.safe_amount == trade.amount): # trade is still open - trade.set_liquidation_price(self.exchange.get_liquidation_price( - pair=trade.pair, - open_rate=trade.open_rate, - is_short=trade.is_short, - amount=trade.amount, - stake_amount=trade.stake_amount, - leverage=trade.leverage, - wallet_balance=trade.stake_amount, - )) + trade.set_liquidation_price( + self.exchange.get_liquidation_price( + pair=trade.pair, + open_rate=trade.open_rate, + is_short=trade.is_short, + amount=trade.amount, + stake_amount=trade.stake_amount, + leverage=trade.leverage, + wallet_balance=trade.stake_amount, + ) + ) self._call_adjust_stop(current_date, trade, order.ft_price) # pass return True return False def _process_exit_order( - self, order: Order, trade: LocalTrade, current_time: datetime, row: Tuple, pair: str + self, order: Order, trade: LocalTrade, current_time: datetime, row: Tuple, pair: str ): """ Takes an exit order and processes it, potentially closing the trade. @@ -676,10 +740,13 @@ class Backtesting: self.run_protections(pair, current_time, trade.trade_direction) def _get_exit_for_signal( - self, trade: LocalTrade, row: Tuple, exit_: ExitCheckTuple, - current_time: datetime, - amount: Optional[float] = None) -> Optional[LocalTrade]: - + self, + trade: LocalTrade, + row: Tuple, + exit_: ExitCheckTuple, + current_time: datetime, + amount: Optional[float] = None, + ) -> Optional[LocalTrade]: if exit_.exit_flag: trade.close_date = current_time exit_reason = exit_.exit_reason @@ -691,9 +758,12 @@ class Backtesting: return None # call the custom exit price,with default value as previous close_rate current_profit = trade.calc_profit_ratio(close_rate) - order_type = self.strategy.order_types['exit'] - if exit_.exit_type in (ExitType.EXIT_SIGNAL, ExitType.CUSTOM_EXIT, - ExitType.PARTIAL_EXIT): + order_type = self.strategy.order_types["exit"] + if exit_.exit_type in ( + ExitType.EXIT_SIGNAL, + ExitType.CUSTOM_EXIT, + ExitType.PARTIAL_EXIT, + ): # Checks and adds an exit tag, after checking that the length of the # row has the length for an exit tag column if ( @@ -704,17 +774,21 @@ class Backtesting: ): exit_reason = row[EXIT_TAG_IDX] # Custom exit pricing only for exit-signals - if order_type == 'limit': - rate = strategy_safe_wrapper(self.strategy.custom_exit_price, - default_retval=close_rate)( + if order_type == "limit": + rate = strategy_safe_wrapper( + self.strategy.custom_exit_price, default_retval=close_rate + )( pair=trade.pair, trade=trade, # type: ignore[arg-type] current_time=current_time, - proposed_rate=close_rate, current_profit=current_profit, - exit_tag=exit_reason) + proposed_rate=close_rate, + current_profit=current_profit, + exit_tag=exit_reason, + ) if rate is not None and rate != close_rate: - close_rate = price_to_precision(rate, trade.price_precision, - self.precision_mode) + close_rate = price_to_precision( + rate, trade.price_precision, self.precision_mode + ) # We can't place orders lower than current low. # freqtrade does not support this in live, and the order would fill immediately if trade.is_short: @@ -722,20 +796,22 @@ class Backtesting: else: close_rate = max(close_rate, row[LOW_IDX]) # Confirm trade exit: - time_in_force = self.strategy.order_time_in_force['exit'] + time_in_force = self.strategy.order_time_in_force["exit"] - if (exit_.exit_type not in (ExitType.LIQUIDATION, ExitType.PARTIAL_EXIT) - and not strategy_safe_wrapper( - self.strategy.confirm_trade_exit, default_retval=True)( - pair=trade.pair, - trade=trade, # type: ignore[arg-type] - order_type=order_type, - amount=amount_, - rate=close_rate, - time_in_force=time_in_force, - sell_reason=exit_reason, # deprecated - exit_reason=exit_reason, - current_time=current_time)): + if exit_.exit_type not in ( + ExitType.LIQUIDATION, + ExitType.PARTIAL_EXIT, + ) and not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( + pair=trade.pair, + trade=trade, # type: ignore[arg-type] + order_type=order_type, + amount=amount_, + rate=close_rate, + time_in_force=time_in_force, + sell_reason=exit_reason, # deprecated + exit_reason=exit_reason, + current_time=current_time, + ): return None trade.exit_reason = exit_reason @@ -743,14 +819,21 @@ class Backtesting: return self._exit_trade(trade, row, close_rate, amount_, exit_reason) return None - def _exit_trade(self, trade: LocalTrade, sell_row: Tuple, close_rate: float, - amount: float, exit_reason: Optional[str]) -> Optional[LocalTrade]: + def _exit_trade( + self, + trade: LocalTrade, + sell_row: Tuple, + close_rate: float, + amount: float, + exit_reason: Optional[str], + ) -> Optional[LocalTrade]: self.order_id_counter += 1 exit_candle_time = sell_row[DATE_IDX].to_pydatetime() - order_type = self.strategy.order_types['exit'] + order_type = self.strategy.order_types["exit"] # amount = amount or trade.amount - amount = amount_to_contract_precision(amount or trade.amount, trade.amount_precision, - self.precision_mode, trade.contract_size) + amount = amount_to_contract_precision( + amount or trade.amount, trade.amount_precision, self.precision_mode, trade.contract_size + ) order = Order( id=self.order_id_counter, ft_trade_id=trade.id, @@ -778,9 +861,8 @@ class Backtesting: return trade def _check_trade_exit( - self, trade: LocalTrade, row: Tuple, current_time: datetime + self, trade: LocalTrade, row: Tuple, current_time: datetime ) -> Optional[LocalTrade]: - self._run_funding_fees(trade, current_time) # Check if we need to adjust our current positions @@ -791,9 +873,13 @@ class Backtesting: enter = row[SHORT_IDX] if trade.is_short else row[LONG_IDX] exit_sig = row[ESHORT_IDX] if trade.is_short else row[ELONG_IDX] exits = self.strategy.should_exit( - trade, row[OPEN_IDX], row[DATE_IDX].to_pydatetime(), # type: ignore - enter=enter, exit_=exit_sig, - low=row[LOW_IDX], high=row[HIGH_IDX] + trade, + row[OPEN_IDX], + row[DATE_IDX].to_pydatetime(), # type: ignore + enter=enter, + exit_=exit_sig, + low=row[LOW_IDX], + high=row[HIGH_IDX], ) for exit_ in exits: t = self._get_exit_for_signal(trade, row, exit_, current_time) @@ -806,11 +892,7 @@ class Backtesting: Calculate funding fees if necessary and add them to the trade. """ if self.trading_mode == TradingMode.FUTURES: - - if ( - force - or (current_time.timestamp() % self.funding_fee_timeframe_secs) == 0 - ): + if force or (current_time.timestamp() % self.funding_fee_timeframe_secs) == 0: # Funding fee interval. trade.set_funding_fees( self.exchange.calculate_funding_fees( @@ -818,30 +900,38 @@ class Backtesting: amount=trade.amount, is_short=trade.is_short, open_date=trade.date_last_filled_utc, - close_date=current_time + close_date=current_time, ) ) def get_valid_price_and_stake( - self, pair: str, row: Tuple, propose_rate: float, stake_amount: float, - direction: LongShort, current_time: datetime, entry_tag: Optional[str], - trade: Optional[LocalTrade], order_type: str, price_precision: Optional[float] + self, + pair: str, + row: Tuple, + propose_rate: float, + stake_amount: float, + direction: LongShort, + current_time: datetime, + entry_tag: Optional[str], + trade: Optional[LocalTrade], + order_type: str, + price_precision: Optional[float], ) -> Tuple[float, float, float, float]: - - if order_type == 'limit': - new_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, - default_retval=propose_rate)( + if order_type == "limit": + new_rate = strategy_safe_wrapper( + self.strategy.custom_entry_price, default_retval=propose_rate + )( pair=pair, trade=trade, # type: ignore[arg-type] current_time=current_time, - proposed_rate=propose_rate, entry_tag=entry_tag, + proposed_rate=propose_rate, + entry_tag=entry_tag, side=direction, ) # default value is the open rate # We can't place orders higher than current high (otherwise it'd be a stop limit entry) # which freqtrade does not support in live. if new_rate is not None and new_rate != propose_rate: - propose_rate = price_to_precision(new_rate, price_precision, - self.precision_mode) + propose_rate = price_to_precision(new_rate, price_precision, self.precision_mode) if direction == "short": propose_rate = max(propose_rate, row[LOW_IDX]) else: @@ -852,53 +942,75 @@ class Backtesting: if not pos_adjust: try: stake_amount = self.wallets.get_trade_stake_amount( - pair, self.strategy.max_open_trades, update=False) + pair, self.strategy.max_open_trades, update=False + ) except DependencyException: return 0, 0, 0, 0 max_leverage = self.exchange.get_max_leverage(pair, stake_amount) - leverage = strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)( - pair=pair, - current_time=current_time, - current_rate=row[OPEN_IDX], - proposed_leverage=1.0, - max_leverage=max_leverage, - side=direction, entry_tag=entry_tag, - ) if self.trading_mode != TradingMode.SPOT else 1.0 + leverage = ( + strategy_safe_wrapper(self.strategy.leverage, default_retval=1.0)( + pair=pair, + current_time=current_time, + current_rate=row[OPEN_IDX], + proposed_leverage=1.0, + max_leverage=max_leverage, + side=direction, + entry_tag=entry_tag, + ) + if self.trading_mode != TradingMode.SPOT + else 1.0 + ) # Cap leverage between 1.0 and max_leverage. leverage = min(max(leverage, 1.0), max_leverage) - min_stake_amount = self.exchange.get_min_pair_stake_amount( - pair, propose_rate, -0.05 if not pos_adjust else 0.0, leverage=leverage) or 0 + min_stake_amount = ( + self.exchange.get_min_pair_stake_amount( + pair, propose_rate, -0.05 if not pos_adjust else 0.0, leverage=leverage + ) + or 0 + ) max_stake_amount = self.exchange.get_max_pair_stake_amount( - pair, propose_rate, leverage=leverage) + pair, propose_rate, leverage=leverage + ) stake_available = self.wallets.get_available_stake_amount() if not pos_adjust: - stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, - default_retval=stake_amount)( - pair=pair, current_time=current_time, current_rate=propose_rate, - proposed_stake=stake_amount, min_stake=min_stake_amount, + stake_amount = strategy_safe_wrapper( + self.strategy.custom_stake_amount, default_retval=stake_amount + )( + pair=pair, + current_time=current_time, + current_rate=propose_rate, + proposed_stake=stake_amount, + min_stake=min_stake_amount, max_stake=min(stake_available, max_stake_amount), - leverage=leverage, entry_tag=entry_tag, side=direction) + leverage=leverage, + entry_tag=entry_tag, + side=direction, + ) stake_amount_val = self.wallets.validate_stake_amount( pair=pair, stake_amount=stake_amount, min_stake_amount=min_stake_amount, max_stake_amount=max_stake_amount, - trade_amount=trade.stake_amount if trade else None + trade_amount=trade.stake_amount if trade else None, ) return propose_rate, stake_amount_val, leverage, min_stake_amount - def _enter_trade(self, pair: str, row: Tuple, direction: LongShort, - stake_amount: Optional[float] = None, - trade: Optional[LocalTrade] = None, - requested_rate: Optional[float] = None, - requested_stake: Optional[float] = None, - entry_tag1: Optional[str] = None - ) -> Optional[LocalTrade]: + def _enter_trade( + self, + pair: str, + row: Tuple, + direction: LongShort, + stake_amount: Optional[float] = None, + trade: Optional[LocalTrade] = None, + requested_rate: Optional[float] = None, + requested_stake: Optional[float] = None, + entry_tag1: Optional[str] = None, + ) -> Optional[LocalTrade]: """ :param trade: Trade to adjust - initial entry if None :param requested_rate: Adjusted entry rate @@ -908,15 +1020,23 @@ class Backtesting: current_time = row[DATE_IDX].to_pydatetime() entry_tag = entry_tag1 or (row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None) # let's call the custom entry price, using the open price as default price - order_type = self.strategy.order_types['entry'] + order_type = self.strategy.order_types["entry"] pos_adjust = trade is not None and requested_rate is None stake_amount_ = stake_amount or (trade.stake_amount if trade else 0.0) precision_price = self.exchange.get_precision_price(pair) propose_rate, stake_amount, leverage, min_stake_amount = self.get_valid_price_and_stake( - pair, row, row[OPEN_IDX], stake_amount_, direction, current_time, entry_tag, trade, - order_type, precision_price, + pair, + row, + row[OPEN_IDX], + stake_amount_, + direction, + current_time, + entry_tag, + trade, + order_type, + precision_price, ) # replace proposed rate if another rate was requested @@ -927,7 +1047,7 @@ class Backtesting: # In case of pos adjust, still return the original trade # If not pos adjust, trade is None return trade - time_in_force = self.strategy.order_time_in_force['entry'] + time_in_force = self.strategy.order_time_in_force["entry"] if stake_amount and (not min_stake_amount or stake_amount >= min_stake_amount): self.order_id_counter += 1 @@ -936,8 +1056,9 @@ class Backtesting: contract_size = self.exchange.get_contract_size(pair) precision_amount = self.exchange.get_precision_amount(pair) - amount = amount_to_contract_precision(amount_p, precision_amount, self.precision_mode, - contract_size) + amount = amount_to_contract_precision( + amount_p, precision_amount, self.precision_mode, contract_size + ) if not amount: # No amount left after truncating to precision. return trade @@ -947,13 +1068,20 @@ class Backtesting: if not pos_adjust: # Confirm trade entry: if not strategy_safe_wrapper( - self.strategy.confirm_trade_entry, default_retval=True)( - pair=pair, order_type=order_type, amount=amount, rate=propose_rate, - time_in_force=time_in_force, current_time=current_time, - entry_tag=entry_tag, side=direction): + self.strategy.confirm_trade_entry, default_retval=True + )( + pair=pair, + order_type=order_type, + amount=amount, + rate=propose_rate, + time_in_force=time_in_force, + current_time=current_time, + entry_tag=entry_tag, + side=direction, + ): return trade - is_short = (direction == 'short') + is_short = direction == "short" # Necessary for Margin trading. Disabled until support is enabled. # interest_rate = self.exchange.get_interest_rate() @@ -964,7 +1092,7 @@ class Backtesting: id=self.trade_id_counter, pair=pair, base_currency=base_currency, - stake_currency=self.config['stake_currency'], + stake_currency=self.config["stake_currency"], open_rate=propose_rate, open_rate_requested=propose_rate, open_date=current_time, @@ -1020,8 +1148,9 @@ class Backtesting: return trade - def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]], - data: Dict[str, List[Tuple]]) -> None: + def handle_left_open( + self, open_trades: Dict[str, List[LocalTrade]], data: Dict[str, List[Tuple]] + ) -> None: """ Handling of left open trades at the end of backtesting """ @@ -1031,8 +1160,9 @@ class Backtesting: # Ignore trade if entry-order did not fill yet continue exit_row = data[pair][-1] - self._exit_trade(trade, exit_row, exit_row[OPEN_IDX], trade.amount, - ExitType.FORCE_EXIT.value) + self._exit_trade( + trade, exit_row, exit_row[OPEN_IDX], trade.amount, ExitType.FORCE_EXIT.value + ) trade.orders[-1].close_bt_order(exit_row[DATE_IDX].to_pydatetime(), trade) trade.close_date = exit_row[DATE_IDX].to_pydatetime() @@ -1057,10 +1187,10 @@ class Backtesting: if enter_long == 1 and not any([exit_long, enter_short]): # Long - return 'long' + return "long" if enter_short == 1 and not any([exit_short, enter_long]): # Short - return 'short' + return "short" return None def run_protections(self, pair: str, current_time: datetime, side: LongShort): @@ -1086,7 +1216,8 @@ class Backtesting: return False def check_order_cancel( - self, trade: LocalTrade, order: Order, current_time: datetime) -> Optional[bool]: + self, trade: LocalTrade, order: Order, current_time: datetime + ) -> Optional[bool]: """ Check if current analyzed order has to be canceled. Returns True if the trade should be Deleted (initial order was canceled), @@ -1095,7 +1226,9 @@ class Backtesting: """ timedout = self.strategy.ft_check_timed_out( trade, # type: ignore[arg-type] - order, current_time) + order, + current_time, + ) if timedout: if order.side == trade.entry_side: self.timedout_entry_orders += 1 @@ -1113,8 +1246,9 @@ class Backtesting: return False return None - def check_order_replace(self, trade: LocalTrade, order: Order, current_time, - row: Tuple) -> bool: + def check_order_replace( + self, trade: LocalTrade, order: Order, current_time, row: Tuple + ) -> bool: """ Check if current analyzed entry order has to be replaced and do so. If user requested cancellation and there are no filled orders in the trade will @@ -1123,12 +1257,17 @@ class Backtesting: """ # only check on new candles for open entry orders if order.side == trade.entry_side and current_time > order.order_date_utc: - requested_rate = strategy_safe_wrapper(self.strategy.adjust_entry_price, - default_retval=order.ft_price)( + requested_rate = strategy_safe_wrapper( + self.strategy.adjust_entry_price, default_retval=order.ft_price + )( trade=trade, # type: ignore[arg-type] - order=order, pair=trade.pair, current_time=current_time, - proposed_rate=row[OPEN_IDX], current_order_rate=order.ft_price, - entry_tag=trade.enter_tag, side=trade.trade_direction + order=order, + pair=trade.pair, + current_time=current_time, + proposed_rate=row[OPEN_IDX], + current_order_rate=order.ft_price, + entry_tag=trade.enter_tag, + side=trade.trade_direction, ) # default value is current order price # cancel existing order whenever a new rate is requested (or None) @@ -1141,22 +1280,26 @@ class Backtesting: # place new order if result was not None if requested_rate: - self._enter_trade(pair=trade.pair, row=row, trade=trade, - requested_rate=requested_rate, - requested_stake=( - order.safe_remaining * order.ft_price / trade.leverage), - direction='short' if trade.is_short else 'long') + self._enter_trade( + pair=trade.pair, + row=row, + trade=trade, + requested_rate=requested_rate, + requested_stake=(order.safe_remaining * order.ft_price / trade.leverage), + direction="short" if trade.is_short else "long", + ) # Delete trade if no successful entries happened (if placing the new order failed) if not trade.has_open_orders and trade.nr_of_successful_entries == 0: return True self.replaced_entry_orders += 1 else: # assumption: there can't be multiple open entry orders at any given time - return (trade.nr_of_successful_entries == 0) + return trade.nr_of_successful_entries == 0 return False def validate_row( - self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]: + self, data: Dict, pair: str, row_index: int, current_time: datetime + ) -> Optional[Tuple]: try: # Row is treated as "current incomplete candle". # entry / exit signals are shifted by 1 to compensate for this. @@ -1177,16 +1320,24 @@ class Backtesting: """ # It could be fun to enable hyperopt mode to write # a loss function to reduce rejected signals - if (self.config.get('export', 'none') == 'signals' and - self.dataprovider.runmode == RunMode.BACKTEST): + if ( + self.config.get("export", "none") == "signals" + and self.dataprovider.runmode == RunMode.BACKTEST + ): if pair not in self.rejected_dict: self.rejected_dict[pair] = [] self.rejected_dict[pair].append([row[DATE_IDX], row[ENTER_TAG_IDX]]) def backtest_loop( - self, row: Tuple, pair: str, current_time: datetime, end_date: datetime, - open_trade_count_start: int, trade_dir: Optional[LongShort], - is_first: bool = True) -> int: + self, + row: Tuple, + pair: str, + current_time: datetime, + end_date: datetime, + open_trade_count_start: int, + trade_dir: Optional[LongShort], + is_first: bool = True, + ) -> int: """ NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized. @@ -1212,7 +1363,7 @@ class Backtesting: and trade_dir is not None and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir) ): - if (self.trade_slot_available(open_trade_count_start)): + if self.trade_slot_available(open_trade_count_start): trade = self._enter_trade(pair, row, trade_dir) if trade: # TODO: hacky workaround to avoid opening > max_open_trades @@ -1239,8 +1390,7 @@ class Backtesting: self._process_exit_order(order, trade, current_time, row, pair) return open_trade_count_start - def backtest(self, processed: Dict, - start_date: datetime, end_date: datetime) -> Dict[str, Any]: + def backtest(self, processed: Dict, start_date: datetime, end_date: datetime) -> Dict[str, Any]: """ Implement backtesting functionality @@ -1265,14 +1415,16 @@ class Backtesting: indexes: Dict = defaultdict(int) current_time = start_date + self.timeframe_td - self.progress.init_step(BacktestState.BACKTEST, int( - (end_date - start_date) / self.timeframe_td)) + self.progress.init_step( + BacktestState.BACKTEST, int((end_date - start_date) / self.timeframe_td) + ) # Loop timerange and get candle for each pair at that point in time while current_time <= end_date: open_trade_count_start = LocalTrade.bt_open_open_trade_count self.check_abort() strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)( - current_time=current_time) + current_time=current_time + ) for i, pair in enumerate(data): row_index = indexes[pair] row = self.validate_row(data, pair, row_index, current_time) @@ -1288,7 +1440,8 @@ class Backtesting: if ( (trade_dir is not None or len(LocalTrade.bt_trades_open_pp[pair]) > 0) - and self.timeframe_detail and pair in self.detail_data + and self.timeframe_detail + and pair in self.detail_data ): # Spread out into detail timeframe. # Should only happen when we are either in a trade for this pair @@ -1297,35 +1450,41 @@ class Backtesting: detail_data = self.detail_data[pair] detail_data = detail_data.loc[ - (detail_data['date'] >= current_detail_time) & - (detail_data['date'] < exit_candle_end) + (detail_data["date"] >= current_detail_time) + & (detail_data["date"] < exit_candle_end) ].copy() if len(detail_data) == 0: # Fall back to "regular" data if no detail data was found for this candle open_trade_count_start = self.backtest_loop( - row, pair, current_time, end_date, - open_trade_count_start, trade_dir) + row, pair, current_time, end_date, open_trade_count_start, trade_dir + ) continue - detail_data.loc[:, 'enter_long'] = row[LONG_IDX] - detail_data.loc[:, 'exit_long'] = row[ELONG_IDX] - detail_data.loc[:, 'enter_short'] = row[SHORT_IDX] - detail_data.loc[:, 'exit_short'] = row[ESHORT_IDX] - detail_data.loc[:, 'enter_tag'] = row[ENTER_TAG_IDX] - detail_data.loc[:, 'exit_tag'] = row[EXIT_TAG_IDX] + detail_data.loc[:, "enter_long"] = row[LONG_IDX] + detail_data.loc[:, "exit_long"] = row[ELONG_IDX] + detail_data.loc[:, "enter_short"] = row[SHORT_IDX] + detail_data.loc[:, "exit_short"] = row[ESHORT_IDX] + detail_data.loc[:, "enter_tag"] = row[ENTER_TAG_IDX] + detail_data.loc[:, "exit_tag"] = row[EXIT_TAG_IDX] is_first = True current_time_det = current_time for det_row in detail_data[HEADERS].values.tolist(): self.dataprovider._set_dataframe_max_date(current_time_det) open_trade_count_start = self.backtest_loop( - det_row, pair, current_time_det, end_date, - open_trade_count_start, trade_dir, is_first) + det_row, + pair, + current_time_det, + end_date, + open_trade_count_start, + trade_dir, + is_first, + ) current_time_det += self.timeframe_detail_td is_first = False else: self.dataprovider._set_dataframe_max_date(current_time) open_trade_count_start = self.backtest_loop( - row, pair, current_time, end_date, - open_trade_count_start, trade_dir) + row, pair, current_time, end_date, open_trade_count_start, trade_dir + ) # Move time one configured time_interval ahead. self.progress.increment() @@ -1336,20 +1495,21 @@ class Backtesting: results = trade_list_to_dataframe(LocalTrade.trades) return { - 'results': results, - 'config': self.strategy.config, - 'locks': PairLocks.get_all_locks(), - 'rejected_signals': self.rejected_trades, - 'timedout_entry_orders': self.timedout_entry_orders, - 'timedout_exit_orders': self.timedout_exit_orders, - 'canceled_trade_entries': self.canceled_trade_entries, - 'canceled_entry_orders': self.canceled_entry_orders, - 'replaced_entry_orders': self.replaced_entry_orders, - 'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']), + "results": results, + "config": self.strategy.config, + "locks": PairLocks.get_all_locks(), + "rejected_signals": self.rejected_trades, + "timedout_entry_orders": self.timedout_entry_orders, + "timedout_exit_orders": self.timedout_exit_orders, + "canceled_trade_entries": self.canceled_trade_entries, + "canceled_entry_orders": self.canceled_entry_orders, + "replaced_entry_orders": self.replaced_entry_orders, + "final_balance": self.wallets.get_total(self.strategy.config["stake_currency"]), } - def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, DataFrame], - timerange: TimeRange): + def backtest_one_strategy( + self, strat: IStrategy, data: Dict[str, DataFrame], timerange: TimeRange + ): self.progress.init_step(BacktestState.ANALYZE, 0) strategy_name = strat.get_strategy_name() logger.info(f"Running backtesting for Strategy {strategy_name}") @@ -1357,11 +1517,10 @@ class Backtesting: self._set_strategy(strat) # Use max_open_trades in backtesting, except --disable-max-market-positions is set - if not self.config.get('use_max_market_positions', True): - logger.info( - 'Ignoring max_open_trades (--disable-max-market-positions was used) ...') - self.strategy.max_open_trades = float('inf') - self.config.update({'max_open_trades': self.strategy.max_open_trades}) + if not self.config.get("use_max_market_positions", True): + logger.info("Ignoring max_open_trades (--disable-max-market-positions was used) ...") + self.strategy.max_open_trades = float("inf") + self.config.update({"max_open_trades": self.strategy.max_open_trades}) # need to reprocess data every time to populate signals preprocessed = self.strategy.advise_all_indicators(data) @@ -1371,15 +1530,16 @@ class Backtesting: preprocessed_tmp = trim_dataframes(preprocessed, timerange, self.required_startup) if not preprocessed_tmp: - raise OperationalException( - "No data left after adjusting for startup candles.") + raise OperationalException("No data left after adjusting for startup candles.") # Use preprocessed_tmp for date generation (the trimmed dataframe). # Backtesting will re-trim the dataframes after entry/exit signal generation. min_date, max_date = history.get_timerange(preprocessed_tmp) - 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).') + logger.info( + f"Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} " + f"up to {max_date.strftime(DATETIME_PRINT_FORMAT)} " + f"({(max_date - min_date).days} days)." + ) # Execute backtest and store results results = self.backtest( processed=preprocessed, @@ -1387,32 +1547,38 @@ class Backtesting: end_date=max_date, ) backtest_end_time = datetime.now(timezone.utc) - results.update({ - 'run_id': self.run_ids.get(strategy_name, ''), - 'backtest_start_time': int(backtest_start_time.timestamp()), - 'backtest_end_time': int(backtest_end_time.timestamp()), - }) + results.update( + { + "run_id": self.run_ids.get(strategy_name, ""), + "backtest_start_time": int(backtest_start_time.timestamp()), + "backtest_end_time": int(backtest_end_time.timestamp()), + } + ) self.all_results[strategy_name] = results - if (self.config.get('export', 'none') == 'signals' and - self.dataprovider.runmode == RunMode.BACKTEST): + if ( + self.config.get("export", "none") == "signals" + and self.dataprovider.runmode == RunMode.BACKTEST + ): self.processed_dfs[strategy_name] = generate_trade_signal_candles( - preprocessed_tmp, results) + preprocessed_tmp, results + ) self.rejected_df[strategy_name] = generate_rejected_signals( - preprocessed_tmp, self.rejected_dict) + preprocessed_tmp, self.rejected_dict + ) 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) + backtest_cache_age = self.config.get("backtest_cache", constants.BACKTEST_CACHE_DEFAULT) if self.timerange.stopts == 0 or self.timerange.stopdt > datetime.now(tz=timezone.utc): - logger.warning('Backtest result caching disabled due to use of open-ended timerange.') - elif backtest_cache_age == 'day': + 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': + elif backtest_cache_age == "week": min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1) - elif backtest_cache_age == 'month': + elif backtest_cache_age == "month": min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4) return min_backtest_date @@ -1427,7 +1593,8 @@ class Backtesting: min_backtest_date = self._get_min_cached_backtest_date() if min_backtest_date is not None: self.results = find_existing_backtest_stats( - self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date) + self.config["user_data_dir"] / "backtest_results", self.run_ids, min_backtest_date + ) def start(self) -> None: """ @@ -1442,42 +1609,53 @@ class Backtesting: self.load_prior_backtest() 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"]: # When previous result hash matches - reuse that result and skip backtesting. - logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}') + logger.info(f"Reusing result of previous backtest for {strat.get_strategy_name()}") continue min_date, max_date = self.backtest_one_strategy(strat, data, timerange) # Update old results with new ones. if len(self.all_results) > 0: results = generate_backtest_stats( - data, self.all_results, min_date=min_date, max_date=max_date) + data, self.all_results, min_date=min_date, max_date=max_date + ) if self.results: - self.results['metadata'].update(results['metadata']) - self.results['strategy'].update(results['strategy']) - self.results['strategy_comparison'].extend(results['strategy_comparison']) + self.results["metadata"].update(results["metadata"]) + self.results["strategy"].update(results["strategy"]) + self.results["strategy_comparison"].extend(results["strategy_comparison"]) else: self.results = results dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - if self.config.get('export', 'none') in ('trades', 'signals'): + if self.config.get("export", "none") in ("trades", "signals"): combined_res = combined_dataframes_with_rel_mean(data, min_date, max_date) - store_backtest_stats(self.config['exportfilename'], self.results, dt_appendix, - market_change_data=combined_res) + store_backtest_stats( + self.config["exportfilename"], + self.results, + dt_appendix, + market_change_data=combined_res, + ) - if (self.config.get('export', 'none') == 'signals' and - self.dataprovider.runmode == RunMode.BACKTEST): + if ( + self.config.get("export", "none") == "signals" + and self.dataprovider.runmode == RunMode.BACKTEST + ): store_backtest_analysis_results( - self.config['exportfilename'], self.processed_dfs, self.rejected_df, - dt_appendix) + self.config["exportfilename"], self.processed_dfs, self.rejected_df, dt_appendix + ) # Results may be mixed up now. Sort them so they follow --strategy-list order. - if 'strategy_list' in self.config and len(self.results) > 0: - self.results['strategy_comparison'] = sorted( - self.results['strategy_comparison'], - key=lambda c: self.config['strategy_list'].index(c['key'])) - self.results['strategy'] = dict( - sorted(self.results['strategy'].items(), - key=lambda kv: self.config['strategy_list'].index(kv[0]))) + if "strategy_list" in self.config and len(self.results) > 0: + self.results["strategy_comparison"] = sorted( + self.results["strategy_comparison"], + key=lambda c: self.config["strategy_list"].index(c["key"]), + ) + self.results["strategy"] = dict( + sorted( + self.results["strategy"].items(), + key=lambda kv: self.config["strategy_list"].index(kv[0]), + ) + ) if len(self.strategylist) > 0: # Show backtest results diff --git a/freqtrade/optimize/base_analysis.py b/freqtrade/optimize/base_analysis.py index eb0a5e002..2503ede72 100644 --- a/freqtrade/optimize/base_analysis.py +++ b/freqtrade/optimize/base_analysis.py @@ -25,7 +25,6 @@ class VarHolder: class BaseAnalysis: - def __init__(self, config: Dict[str, Any], strategy_obj: Dict): self.failed_bias_check = True self.full_varHolder = VarHolder() @@ -34,7 +33,7 @@ class BaseAnalysis: # pull variables the scope of the lookahead_analysis-instance self.local_config = deepcopy(config) - self.local_config['strategy'] = strategy_obj['name'] + self.local_config["strategy"] = strategy_obj["name"] self.strategy_obj = strategy_obj @staticmethod @@ -46,7 +45,7 @@ class BaseAnalysis: self.full_varHolder = VarHolder() # define datetime in human-readable format - parsed_timerange = TimeRange.parse_timerange(self.local_config['timerange']) + parsed_timerange = TimeRange.parse_timerange(self.local_config["timerange"]) if parsed_timerange.startdt is None: self.full_varHolder.from_dt = datetime.fromtimestamp(0, tz=timezone.utc) @@ -58,9 +57,8 @@ class BaseAnalysis: else: self.full_varHolder.to_dt = parsed_timerange.stopdt - self.prepare_data(self.full_varHolder, self.local_config['pairs']) + self.prepare_data(self.full_varHolder, self.local_config["pairs"]) def start(self) -> None: - # first make a single backtest self.fill_full_varholder() diff --git a/freqtrade/optimize/bt_progress.py b/freqtrade/optimize/bt_progress.py index c3b105915..a49fe0d86 100644 --- a/freqtrade/optimize/bt_progress.py +++ b/freqtrade/optimize/bt_progress.py @@ -25,8 +25,9 @@ class BTProgress: """ Get progress as ratio, capped to be between 0 and 1 (to avoid small calculation errors). """ - return max(min(round(self._progress / self._max_steps, 5) - if self._max_steps > 0 else 0, 1), 0) + return max( + min(round(self._progress / self._max_steps, 5) if self._max_steps > 0 else 0, 1), 0 + ) @property def action(self): diff --git a/freqtrade/optimize/edge_cli.py b/freqtrade/optimize/edge_cli.py index 07c54d720..9bd8ff1c9 100644 --- a/freqtrade/optimize/edge_cli.py +++ b/freqtrade/optimize/edge_cli.py @@ -3,6 +3,7 @@ """ This module contains the edge backtesting interface """ + import logging from freqtrade import constants @@ -30,8 +31,8 @@ class EdgeCli: self.config = config # Ensure using dry-run - self.config['dry_run'] = True - self.config['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT + self.config["dry_run"] = True + self.config["stake_amount"] = constants.UNLIMITED_STAKE_AMOUNT self.exchange = ExchangeResolver.load_exchange(self.config) self.strategy = StrategyResolver.load_strategy(self.config) self.strategy.dp = DataProvider(config, self.exchange) @@ -42,12 +43,13 @@ class EdgeCli: # Set refresh_pairs to false for edge-cli (it must be true for edge) self.edge._refresh_pairs = False - self.edge._timerange = TimeRange.parse_timerange(None if self.config.get( - 'timerange') is None else str(self.config.get('timerange'))) + self.edge._timerange = TimeRange.parse_timerange( + None if self.config.get("timerange") is None else str(self.config.get("timerange")) + ) self.strategy.ft_bot_start() def start(self) -> None: - result = self.edge.calculate(self.config['exchange']['pair_whitelist']) + result = self.edge.calculate(self.config["exchange"]["pair_whitelist"]) if result: - print('') # blank line for readability + print("") # blank line for readability print(generate_edge_table(self.edge._cached_pairs)) diff --git a/freqtrade/optimize/optimize_reports/bt_output.py b/freqtrade/optimize/optimize_reports/bt_output.py index fdb284f3e..4a634f83b 100644 --- a/freqtrade/optimize/optimize_reports/bt_output.py +++ b/freqtrade/optimize/optimize_reports/bt_output.py @@ -16,28 +16,34 @@ def _get_line_floatfmt(stake_currency: str) -> List[str]: """ Generate floatformat (goes in line with _generate_result_line()) """ - return ['s', 'd', '.2f', f'.{decimals_per_coin(stake_currency)}f', - '.2f', 'd', 's', 's'] + return ["s", "d", ".2f", f".{decimals_per_coin(stake_currency)}f", ".2f", "d", "s", "s"] -def _get_line_header(first_column: str, stake_currency: str, - direction: str = 'Entries') -> List[str]: +def _get_line_header( + first_column: str, stake_currency: str, direction: str = "Entries" +) -> List[str]: """ Generate header lines (goes in line with _generate_result_line()) """ - return [first_column, direction, 'Avg Profit %', - f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration', - 'Win Draw Loss Win%'] + return [ + first_column, + direction, + "Avg Profit %", + f"Tot Profit {stake_currency}", + "Tot Profit %", + "Avg Duration", + "Win Draw Loss Win%", + ] def generate_wins_draws_losses(wins, draws, losses): if wins > 0 and losses == 0: - wl_ratio = '100' + wl_ratio = "100" elif wins == 0: - wl_ratio = '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}' + 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 text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: str) -> str: @@ -48,16 +54,22 @@ def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: st :return: pretty printed table with tabulate as string """ - headers = _get_line_header('Pair', stake_currency) + headers = _get_line_header("Pair", stake_currency) floatfmt = _get_line_floatfmt(stake_currency) - output = [[ - t['key'], t['trades'], t['profit_mean_pct'], t['profit_total_abs'], - t['profit_total_pct'], t['duration_avg'], - generate_wins_draws_losses(t['wins'], t['draws'], t['losses']) - ] for t in pair_results] + output = [ + [ + t["key"], + t["trades"], + t["profit_mean_pct"], + t["profit_total_abs"], + 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, - floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") + return tabulate(output, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_currency: str) -> str: @@ -67,34 +79,35 @@ def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_curr :param stake_currency: stake-currency - used to correctly name headers :return: pretty printed table with tabulate as string """ - fallback: str = '' - if (tag_type == "enter_tag"): + fallback: str = "" + if tag_type == "enter_tag": headers = _get_line_header("TAG", stake_currency) else: - headers = _get_line_header("Exit Reason", stake_currency, 'Exits') - fallback = 'exit_reason' + headers = _get_line_header("Exit Reason", stake_currency, "Exits") + fallback = "exit_reason" floatfmt = _get_line_floatfmt(stake_currency) output = [ [ - t['key'] if t.get('key') is not None and len( - str(t['key'])) > 0 else t.get(fallback, "OTHER"), - t['trades'], - t['profit_mean_pct'], - t['profit_total_abs'], - t['profit_total_pct'], - t.get('duration_avg'), - generate_wins_draws_losses( - t['wins'], - t['draws'], - t['losses'])] for t in tag_results] + t["key"] + if t.get("key") is not None and len(str(t["key"])) > 0 + else t.get(fallback, "OTHER"), + t["trades"], + t["profit_mean_pct"], + t["profit_total_abs"], + t["profit_total_pct"], + t.get("duration_avg"), + generate_wins_draws_losses(t["wins"], t["draws"], t["losses"]), + ] + for t in tag_results + ] # Ignore type as floatfmt does allow tuples but mypy does not know that - return tabulate(output, headers=headers, - floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") + return tabulate(output, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") -def text_table_periodic_breakdown(days_breakdown_stats: List[Dict[str, Any]], - stake_currency: str, period: str) -> str: +def text_table_periodic_breakdown( + days_breakdown_stats: List[Dict[str, Any]], stake_currency: str, period: str +) -> str: """ Generate small table with Backtest results by days :param days_breakdown_stats: Days breakdown metrics @@ -103,15 +116,21 @@ def text_table_periodic_breakdown(days_breakdown_stats: List[Dict[str, Any]], """ headers = [ period.capitalize(), - f'Tot Profit {stake_currency}', - 'Wins', - 'Draws', - 'Losses', + f"Tot Profit {stake_currency}", + "Wins", + "Draws", + "Losses", + ] + output = [ + [ + d["date"], + fmt_coin(d["profit_abs"], stake_currency, False), + d["wins"], + d["draws"], + d["loses"], + ] + for d in days_breakdown_stats ] - output = [[ - d['date'], fmt_coin(d['profit_abs'], stake_currency, False), - d['wins'], d['draws'], d['loses'], - ] for d in days_breakdown_stats] return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right") @@ -123,263 +142,352 @@ def text_table_strategy(strategy_results, stake_currency: str) -> str: :return: pretty printed table with tabulate as string """ floatfmt = _get_line_floatfmt(stake_currency) - headers = _get_line_header('Strategy', 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') + headers.append("Drawdown") # Align drawdown string on the center two space separator. - if 'max_drawdown_account' in strategy_results[0]: + if "max_drawdown_account" in strategy_results[0]: drawdown = [f'{t["max_drawdown_account"] * 100:.2f}' for t in strategy_results] else: # Support for prior backtest results 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_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)] + 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_total_abs'], - 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)] + output = [ + [ + t["key"], + t["trades"], + t["profit_mean_pct"], + t["profit_total_abs"], + 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") + return tabulate(output, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") 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']) + 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"]) - short_metrics = [ - ('', ''), # Empty line to improve readability - ('Long / Short', - f"{strat_results.get('trade_count_long', 'total_trades')} / " - f"{strat_results.get('trade_count_short', 0)}"), - ('Total profit Long %', f"{strat_results['profit_total_long']:.2%}"), - ('Total profit Short %', f"{strat_results['profit_total_short']:.2%}"), - ('Absolute profit Long', fmt_coin(strat_results['profit_total_long_abs'], - strat_results['stake_currency'])), - ('Absolute profit Short', fmt_coin(strat_results['profit_total_short_abs'], - strat_results['stake_currency'])), - ] if strat_results.get('trade_count_short', 0) > 0 else [] + short_metrics = ( + [ + ("", ""), # Empty line to improve readability + ( + "Long / Short", + f"{strat_results.get('trade_count_long', 'total_trades')} / " + f"{strat_results.get('trade_count_short', 0)}", + ), + ("Total profit Long %", f"{strat_results['profit_total_long']:.2%}"), + ("Total profit Short %", f"{strat_results['profit_total_short']:.2%}"), + ( + "Absolute profit Long", + fmt_coin( + strat_results["profit_total_long_abs"], strat_results["stake_currency"] + ), + ), + ( + "Absolute profit Short", + fmt_coin( + strat_results["profit_total_short_abs"], strat_results["stake_currency"] + ), + ), + ] + if strat_results.get("trade_count_short", 0) > 0 + else [] + ) drawdown_metrics = [] - if 'max_relative_drawdown' in strat_results: + if "max_relative_drawdown" in strat_results: # Compatibility to show old hyperopt results drawdown_metrics.append( - ('Max % of account underwater', f"{strat_results['max_relative_drawdown']:.2%}") + ("Max % of account underwater", f"{strat_results['max_relative_drawdown']:.2%}") ) - drawdown_metrics.extend([ - ('Absolute Drawdown (Account)', f"{strat_results['max_drawdown_account']:.2%}") - if 'max_drawdown_account' in strat_results else ( - 'Drawdown', f"{strat_results['max_drawdown']:.2%}"), - ('Absolute Drawdown', fmt_coin(strat_results['max_drawdown_abs'], - strat_results['stake_currency'])), - ('Drawdown high', fmt_coin(strat_results['max_drawdown_high'], - strat_results['stake_currency'])), - ('Drawdown low', fmt_coin(strat_results['max_drawdown_low'], - strat_results['stake_currency'])), - ('Drawdown Start', strat_results['drawdown_start']), - ('Drawdown End', strat_results['drawdown_end']), - ]) + drawdown_metrics.extend( + [ + ("Absolute Drawdown (Account)", f"{strat_results['max_drawdown_account']:.2%}") + if "max_drawdown_account" in strat_results + else ("Drawdown", f"{strat_results['max_drawdown']:.2%}"), + ( + "Absolute Drawdown", + fmt_coin(strat_results["max_drawdown_abs"], strat_results["stake_currency"]), + ), + ( + "Drawdown high", + fmt_coin(strat_results["max_drawdown_high"], strat_results["stake_currency"]), + ), + ( + "Drawdown low", + fmt_coin(strat_results["max_drawdown_low"], strat_results["stake_currency"]), + ), + ("Drawdown Start", strat_results["drawdown_start"]), + ("Drawdown End", strat_results["drawdown_end"]), + ] + ) - entry_adjustment_metrics = [ - ('Canceled Trade Entries', strat_results.get('canceled_trade_entries', 'N/A')), - ('Canceled Entry Orders', strat_results.get('canceled_entry_orders', 'N/A')), - ('Replaced Entry Orders', strat_results.get('replaced_entry_orders', 'N/A')), - ] if strat_results.get('canceled_entry_orders', 0) > 0 else [] + entry_adjustment_metrics = ( + [ + ("Canceled Trade Entries", strat_results.get("canceled_trade_entries", "N/A")), + ("Canceled Entry Orders", strat_results.get("canceled_entry_orders", "N/A")), + ("Replaced Entry Orders", strat_results.get("replaced_entry_orders", "N/A")), + ] + if strat_results.get("canceled_entry_orders", 0) > 0 + else [] + ) # 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. metrics = [ - ('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/Daily Avg Trades', - f"{strat_results['total_trades']} / {strat_results['trades_per_day']}"), - - ('Starting balance', fmt_coin(strat_results['starting_balance'], - strat_results['stake_currency'])), - ('Final balance', fmt_coin(strat_results['final_balance'], - strat_results['stake_currency'])), - ('Absolute profit ', fmt_coin(strat_results['profit_total_abs'], - strat_results['stake_currency'])), - ('Total profit %', f"{strat_results['profit_total']:.2%}"), - ('CAGR %', f"{strat_results['cagr']:.2%}" if 'cagr' in strat_results else 'N/A'), - ('Sortino', f"{strat_results['sortino']:.2f}" if 'sortino' in strat_results else 'N/A'), - ('Sharpe', f"{strat_results['sharpe']:.2f}" if 'sharpe' in strat_results else 'N/A'), - ('Calmar', f"{strat_results['calmar']:.2f}" if 'calmar' in strat_results else 'N/A'), - ('Profit factor', f'{strat_results["profit_factor"]:.2f}' if 'profit_factor' - in strat_results else 'N/A'), - ('Expectancy (Ratio)', ( - f"{strat_results['expectancy']:.2f} ({strat_results['expectancy_ratio']:.2f})" if - 'expectancy_ratio' in strat_results else 'N/A')), - ('Avg. daily profit %', - f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"), - ('Avg. stake amount', fmt_coin(strat_results['avg_stake_amount'], - strat_results['stake_currency'])), - ('Total trade volume', fmt_coin(strat_results['total_volume'], - strat_results['stake_currency'])), + ("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/Daily Avg Trades", + f"{strat_results['total_trades']} / {strat_results['trades_per_day']}", + ), + ( + "Starting balance", + fmt_coin(strat_results["starting_balance"], strat_results["stake_currency"]), + ), + ( + "Final balance", + fmt_coin(strat_results["final_balance"], strat_results["stake_currency"]), + ), + ( + "Absolute profit ", + fmt_coin(strat_results["profit_total_abs"], strat_results["stake_currency"]), + ), + ("Total profit %", f"{strat_results['profit_total']:.2%}"), + ("CAGR %", f"{strat_results['cagr']:.2%}" if "cagr" in strat_results else "N/A"), + ("Sortino", f"{strat_results['sortino']:.2f}" if "sortino" in strat_results else "N/A"), + ("Sharpe", f"{strat_results['sharpe']:.2f}" if "sharpe" in strat_results else "N/A"), + ("Calmar", f"{strat_results['calmar']:.2f}" if "calmar" in strat_results else "N/A"), + ( + "Profit factor", + f'{strat_results["profit_factor"]:.2f}' + if "profit_factor" in strat_results + else "N/A", + ), + ( + "Expectancy (Ratio)", + ( + f"{strat_results['expectancy']:.2f} ({strat_results['expectancy_ratio']:.2f})" + if "expectancy_ratio" in strat_results + else "N/A" + ), + ), + ( + "Avg. daily profit %", + f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}", + ), + ( + "Avg. stake amount", + fmt_coin(strat_results["avg_stake_amount"], strat_results["stake_currency"]), + ), + ( + "Total trade volume", + fmt_coin(strat_results["total_volume"], strat_results["stake_currency"]), + ), *short_metrics, - ('', ''), # Empty line to improve readability - ('Best Pair', f"{strat_results['best_pair']['key']} " - f"{strat_results['best_pair']['profit_total']:.2%}"), - ('Worst Pair', f"{strat_results['worst_pair']['key']} " - f"{strat_results['worst_pair']['profit_total']:.2%}"), - ('Best trade', f"{best_trade['pair']} {best_trade['profit_ratio']:.2%}"), - ('Worst trade', f"{worst_trade['pair']} " - f"{worst_trade['profit_ratio']:.2%}"), - - ('Best day', fmt_coin(strat_results['backtest_best_day_abs'], - strat_results['stake_currency'])), - ('Worst day', fmt_coin(strat_results['backtest_worst_day_abs'], - strat_results['stake_currency'])), - ('Days win/draw/lose', f"{strat_results['winning_days']} / " - 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']}"), - ('Max Consecutive Wins / Loss', - f"{strat_results['max_consecutive_wins']} / {strat_results['max_consecutive_losses']}" - if 'max_consecutive_losses' in strat_results else 'N/A'), - ('Rejected Entry signals', strat_results.get('rejected_signals', 'N/A')), - ('Entry/Exit Timeouts', - f"{strat_results.get('timedout_entry_orders', 'N/A')} / " - f"{strat_results.get('timedout_exit_orders', 'N/A')}"), + ("", ""), # Empty line to improve readability + ( + "Best Pair", + f"{strat_results['best_pair']['key']} " + f"{strat_results['best_pair']['profit_total']:.2%}", + ), + ( + "Worst Pair", + f"{strat_results['worst_pair']['key']} " + f"{strat_results['worst_pair']['profit_total']:.2%}", + ), + ("Best trade", f"{best_trade['pair']} {best_trade['profit_ratio']:.2%}"), + ("Worst trade", f"{worst_trade['pair']} " f"{worst_trade['profit_ratio']:.2%}"), + ( + "Best day", + fmt_coin(strat_results["backtest_best_day_abs"], strat_results["stake_currency"]), + ), + ( + "Worst day", + fmt_coin(strat_results["backtest_worst_day_abs"], strat_results["stake_currency"]), + ), + ( + "Days win/draw/lose", + f"{strat_results['winning_days']} / " + 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']}"), + ( + "Max Consecutive Wins / Loss", + f"{strat_results['max_consecutive_wins']} / {strat_results['max_consecutive_losses']}" + if "max_consecutive_losses" in strat_results + else "N/A", + ), + ("Rejected Entry signals", strat_results.get("rejected_signals", "N/A")), + ( + "Entry/Exit Timeouts", + f"{strat_results.get('timedout_entry_orders', 'N/A')} / " + f"{strat_results.get('timedout_exit_orders', 'N/A')}", + ), *entry_adjustment_metrics, - ('', ''), # Empty line to improve readability - - ('Min balance', fmt_coin(strat_results['csum_min'], strat_results['stake_currency'])), - ('Max balance', fmt_coin(strat_results['csum_max'], strat_results['stake_currency'])), - + ("", ""), # Empty line to improve readability + ("Min balance", fmt_coin(strat_results["csum_min"], strat_results["stake_currency"])), + ("Max balance", fmt_coin(strat_results["csum_max"], strat_results["stake_currency"])), *drawdown_metrics, - ('Market change', f"{strat_results['market_change']:.2%}"), + ("Market change", f"{strat_results['market_change']:.2%}"), ] return tabulate(metrics, headers=["Metric", "Value"], tablefmt="orgtbl") else: - start_balance = fmt_coin(strat_results['starting_balance'], strat_results['stake_currency']) - stake_amount = fmt_coin( - strat_results['stake_amount'], strat_results['stake_currency'] - ) if strat_results['stake_amount'] != UNLIMITED_STAKE_AMOUNT else 'unlimited' + start_balance = fmt_coin(strat_results["starting_balance"], strat_results["stake_currency"]) + stake_amount = ( + fmt_coin(strat_results["stake_amount"], strat_results["stake_currency"]) + if strat_results["stake_amount"] != UNLIMITED_STAKE_AMOUNT + else "unlimited" + ) - message = ("No trades made. " - f"Your starting balance was {start_balance}, " - f"and your stake was {stake_amount}." - ) + message = ( + "No trades made. " + f"Your starting balance was {start_balance}, " + f"and your stake was {stake_amount}." + ) return message -def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: str, - backtest_breakdown: List[str]): +def show_backtest_result( + strategy: str, results: Dict[str, Any], stake_currency: str, backtest_breakdown: List[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) + 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(" BACKTESTING REPORT ".center(len(table.splitlines()[0]), "=")) print(table) - table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency) + 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(" LEFT OPEN TRADES REPORT ".center(len(table.splitlines()[0]), "=")) print(table) - if (enter_tags := results.get('results_per_enter_tag')) is not None: + if (enter_tags := results.get("results_per_enter_tag")) is not None: table = text_table_tags("enter_tag", enter_tags, stake_currency) if isinstance(table, str) and len(table) > 0: - print(' ENTER TAG STATS '.center(len(table.splitlines()[0]), '=')) + print(" ENTER TAG STATS ".center(len(table.splitlines()[0]), "=")) print(table) - if (exit_reasons := results.get('exit_reason_summary')) is not None: + if (exit_reasons := results.get("exit_reason_summary")) is not None: table = text_table_tags("exit_tag", exit_reasons, stake_currency) if isinstance(table, str) and len(table) > 0: - print(' EXIT REASON STATS '.center(len(table.splitlines()[0]), '=')) + print(" EXIT REASON STATS ".center(len(table.splitlines()[0]), "=")) print(table) for period in backtest_breakdown: - if period in results.get('periodic_breakdown', {}): - days_breakdown_stats = results['periodic_breakdown'][period] + if period in results.get("periodic_breakdown", {}): + days_breakdown_stats = results["periodic_breakdown"][period] else: days_breakdown_stats = generate_periodic_breakdown_stats( - trade_list=results['trades'], period=period) - table = text_table_periodic_breakdown(days_breakdown_stats=days_breakdown_stats, - stake_currency=stake_currency, period=period) + trade_list=results["trades"], period=period + ) + table = text_table_periodic_breakdown( + days_breakdown_stats=days_breakdown_stats, stake_currency=stake_currency, period=period + ) if isinstance(table, str) and len(table) > 0: - print(f' {period.upper()} BREAKDOWN '.center(len(table.splitlines()[0]), '=')) + print(f" {period.upper()} BREAKDOWN ".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(" SUMMARY METRICS ".center(len(table.splitlines()[0]), "=")) print(table) if isinstance(table, str) and len(table) > 0: - print('=' * len(table.splitlines()[0])) + print("=" * len(table.splitlines()[0])) print() def show_backtest_results(config: Config, backtest_stats: BacktestResultType): - stake_currency = config['stake_currency'] + stake_currency = config["stake_currency"] - for strategy, results in backtest_stats['strategy'].items(): + for strategy, results in backtest_stats["strategy"].items(): show_backtest_result( - strategy, results, stake_currency, - config.get('backtest_breakdown', [])) + strategy, results, stake_currency, config.get("backtest_breakdown", []) + ) - if len(backtest_stats['strategy']) > 0: + if len(backtest_stats["strategy"]) > 0: # Print Strategy summary table - table = text_table_strategy(backtest_stats['strategy_comparison'], stake_currency) - print(f"Backtested {results['backtest_start']} -> {results['backtest_end']} |" - f" Max open trades : {results['max_open_trades']}") - print(' STRATEGY SUMMARY '.center(len(table.splitlines()[0]), '=')) + table = text_table_strategy(backtest_stats["strategy_comparison"], stake_currency) + print( + f"Backtested {results['backtest_start']} -> {results['backtest_end']} |" + f" Max open trades : {results['max_open_trades']}" + ) + print(" STRATEGY SUMMARY ".center(len(table.splitlines()[0]), "=")) print(table) - print('=' * len(table.splitlines()[0])) - print('\nFor more details, please look at the detail tables above') + print("=" * len(table.splitlines()[0])) + print("\nFor more details, please look at the detail tables above") def show_sorted_pairlist(config: Config, backtest_stats: BacktestResultType): - if config.get('backtest_show_pair_list', False): - for strategy, results in backtest_stats['strategy'].items(): + if config.get("backtest_show_pair_list", False): + for strategy, results in backtest_stats["strategy"].items(): print(f"Pairs for Strategy {strategy}: \n[") - for result in results['results_per_pair']: - if result["key"] != 'TOTAL': + for result in results["results_per_pair"]: + if result["key"] != "TOTAL": print(f'"{result["key"]}", // {result["profit_mean"]:.2%}') print("]") def generate_edge_table(results: dict) -> str: - floatfmt = ('s', '.10g', '.2f', '.2f', '.2f', '.2f', 'd', 'd', 'd') + floatfmt = ("s", ".10g", ".2f", ".2f", ".2f", ".2f", "d", "d", "d") tabular_data = [] - headers = ['Pair', 'Stoploss', 'Win Rate', 'Risk Reward Ratio', - 'Required Risk Reward', 'Expectancy', 'Total Number of Trades', - 'Average Duration (min)'] + headers = [ + "Pair", + "Stoploss", + "Win Rate", + "Risk Reward Ratio", + "Required Risk Reward", + "Expectancy", + "Total Number of Trades", + "Average Duration (min)", + ] for result in results.items(): if result[1].nb_trades > 0: - tabular_data.append([ - result[0], - result[1].stoploss, - result[1].winrate, - result[1].risk_reward_ratio, - result[1].required_risk_reward, - result[1].expectancy, - result[1].nb_trades, - round(result[1].avg_trade_duration) - ]) + tabular_data.append( + [ + result[0], + result[1].stoploss, + result[1].winrate, + result[1].risk_reward_ratio, + result[1].required_risk_reward, + result[1].expectancy, + result[1].nb_trades, + round(result[1].avg_trade_duration), + ] + ) # Ignore type as floatfmt does allow tuples but mypy does not know that - return tabulate(tabular_data, headers=headers, - floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") + return tabulate( + tabular_data, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right" + ) diff --git a/freqtrade/optimize/optimize_reports/bt_storage.py b/freqtrade/optimize/optimize_reports/bt_storage.py index a8a8bf7f2..ea8991337 100644 --- a/freqtrade/optimize/optimize_reports/bt_storage.py +++ b/freqtrade/optimize/optimize_reports/bt_storage.py @@ -22,17 +22,21 @@ def _generate_filename(recordfilename: Path, appendix: str, suffix: str) -> Path :return: Generated filename as a Path object """ if recordfilename.is_dir(): - filename = (recordfilename / f'backtest-result-{appendix}').with_suffix(suffix) + filename = (recordfilename / f"backtest-result-{appendix}").with_suffix(suffix) else: filename = Path.joinpath( - recordfilename.parent, f'{recordfilename.stem}-{appendix}' + recordfilename.parent, f"{recordfilename.stem}-{appendix}" ).with_suffix(suffix) return filename def store_backtest_stats( - recordfilename: Path, stats: BacktestResultType, dtappendix: str, *, - market_change_data: Optional[DataFrame] = None) -> Path: + recordfilename: Path, + stats: BacktestResultType, + dtappendix: str, + *, + market_change_data: Optional[DataFrame] = None, +) -> Path: """ Stores backtest results :param recordfilename: Path object, which can either be a filename or a directory. @@ -41,32 +45,33 @@ def store_backtest_stats( :param stats: Dataframe containing the backtesting statistics :param dtappendix: Datetime to use for the filename """ - filename = _generate_filename(recordfilename, dtappendix, '.json') + filename = _generate_filename(recordfilename, dtappendix, ".json") # Store metadata separately. - file_dump_json(get_backtest_metadata_filename(filename), stats['metadata']) + file_dump_json(get_backtest_metadata_filename(filename), stats["metadata"]) # Don't mutate the original stats dict. stats_copy = { - 'strategy': stats['strategy'], - 'strategy_comparison': stats['strategy_comparison'], + "strategy": stats["strategy"], + "strategy_comparison": stats["strategy_comparison"], } file_dump_json(filename, stats_copy) latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN) - file_dump_json(latest_filename, {'latest_backtest': str(filename.name)}) + file_dump_json(latest_filename, {"latest_backtest": str(filename.name)}) if market_change_data is not None: - filename_mc = _generate_filename(recordfilename, f"{dtappendix}_market_change", '.feather') + filename_mc = _generate_filename(recordfilename, f"{dtappendix}_market_change", ".feather") market_change_data.reset_index().to_feather( - filename_mc, compression_level=9, compression='lz4') + filename_mc, compression_level=9, compression="lz4" + ) return filename def _store_backtest_analysis_data( - recordfilename: Path, data: Dict[str, Dict], - dtappendix: str, name: str) -> Path: + recordfilename: Path, data: Dict[str, Dict], dtappendix: str, name: str +) -> Path: """ Stores backtest trade candles for analysis :param recordfilename: Path object, which can either be a filename or a directory. @@ -77,7 +82,7 @@ def _store_backtest_analysis_data( :param dtappendix: Datetime to use for the filename :param name: Name to use for the file, e.g. signals, rejected """ - filename = _generate_filename(recordfilename, f"{dtappendix}_{name}", '.pkl') + filename = _generate_filename(recordfilename, f"{dtappendix}_{name}", ".pkl") file_dump_joblib(filename, data) @@ -85,7 +90,7 @@ def _store_backtest_analysis_data( def store_backtest_analysis_results( - recordfilename: Path, candles: Dict[str, Dict], trades: Dict[str, Dict], - dtappendix: str) -> None: + recordfilename: Path, candles: Dict[str, Dict], trades: Dict[str, Dict], dtappendix: str +) -> None: _store_backtest_analysis_data(recordfilename, candles, dtappendix, "signals") _store_backtest_analysis_data(recordfilename, trades, dtappendix, "rejected") diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index b6a2312af..dd83ca6a9 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -24,43 +24,45 @@ from freqtrade.util import decimals_per_coin, fmt_coin logger = logging.getLogger(__name__) -def generate_trade_signal_candles(preprocessed_df: Dict[str, DataFrame], - bt_results: Dict[str, Any]) -> DataFrame: +def generate_trade_signal_candles( + preprocessed_df: Dict[str, DataFrame], bt_results: Dict[str, Any] +) -> DataFrame: signal_candles_only = {} for pair in preprocessed_df.keys(): signal_candles_only_df = DataFrame() pairdf = preprocessed_df[pair] - resdf = bt_results['results'] + resdf = bt_results["results"] pairresults = resdf.loc[(resdf["pair"] == pair)] if pairdf.shape[0] > 0: for t, v in pairresults.open_date.items(): - allinds = pairdf.loc[(pairdf['date'] < v)] + allinds = pairdf.loc[(pairdf["date"] < v)] signal_inds = allinds.iloc[[-1]] - signal_candles_only_df = concat([ - signal_candles_only_df.infer_objects(), - signal_inds.infer_objects()]) + signal_candles_only_df = concat( + [signal_candles_only_df.infer_objects(), signal_inds.infer_objects()] + ) signal_candles_only[pair] = signal_candles_only_df return signal_candles_only -def generate_rejected_signals(preprocessed_df: Dict[str, DataFrame], - rejected_dict: Dict[str, DataFrame]) -> Dict[str, DataFrame]: +def generate_rejected_signals( + preprocessed_df: Dict[str, DataFrame], rejected_dict: Dict[str, DataFrame] +) -> Dict[str, DataFrame]: rejected_candles_only = {} for pair, signals in rejected_dict.items(): rejected_signals_only_df = DataFrame() pairdf = preprocessed_df[pair] for t in signals: - data_df_row = pairdf.loc[(pairdf['date'] == t[0])].copy() - data_df_row['pair'] = pair - data_df_row['enter_tag'] = t[1] + data_df_row = pairdf.loc[(pairdf["date"] == t[0])].copy() + data_df_row["pair"] = pair + data_df_row["enter_tag"] = t[1] - rejected_signals_only_df = concat([ - rejected_signals_only_df.infer_objects(), - data_df_row.infer_objects()]) + rejected_signals_only_df = concat( + [rejected_signals_only_df.infer_objects(), data_df_row.infer_objects()] + ) rejected_candles_only[pair] = rejected_signals_only_df return rejected_candles_only @@ -70,39 +72,45 @@ def _generate_result_line(result: DataFrame, starting_balance: int, first_column """ Generate one result dict, with "first_column" as key. """ - profit_sum = result['profit_ratio'].sum() + profit_sum = result["profit_ratio"].sum() # (end-capital - starting capital) / starting capital - profit_total = result['profit_abs'].sum() / starting_balance + profit_total = result["profit_abs"].sum() / starting_balance return { - 'key': first_column, - 'trades': len(result), - 'profit_mean': result['profit_ratio'].mean() if len(result) > 0 else 0.0, - 'profit_mean_pct': round(result['profit_ratio'].mean() * 100.0, 2 - ) if len(result) > 0 else 0.0, - 'profit_sum': profit_sum, - 'profit_sum_pct': round(profit_sum * 100.0, 2), - 'profit_total_abs': result['profit_abs'].sum(), - 'profit_total': profit_total, - 'profit_total_pct': round(profit_total * 100.0, 2), - 'duration_avg': str(timedelta( - minutes=round(result['trade_duration'].mean())) - ) if not result.empty else '0:00', + "key": first_column, + "trades": len(result), + "profit_mean": result["profit_ratio"].mean() if len(result) > 0 else 0.0, + "profit_mean_pct": round(result["profit_ratio"].mean() * 100.0, 2) + if len(result) > 0 + else 0.0, + "profit_sum": profit_sum, + "profit_sum_pct": round(profit_sum * 100.0, 2), + "profit_total_abs": result["profit_abs"].sum(), + "profit_total": profit_total, + "profit_total_pct": round(profit_total * 100.0, 2), + "duration_avg": str(timedelta(minutes=round(result["trade_duration"].mean()))) + if not result.empty + else "0:00", # 'duration_max': str(timedelta( # minutes=round(result['trade_duration'].max())) # ) if not result.empty else '0:00', # 'duration_min': str(timedelta( # minutes=round(result['trade_duration'].min())) # ) if not result.empty else '0:00', - 'wins': len(result[result['profit_abs'] > 0]), - 'draws': len(result[result['profit_abs'] == 0]), - 'losses': len(result[result['profit_abs'] < 0]), - 'winrate': len(result[result['profit_abs'] > 0]) / len(result) if len(result) else 0.0, + "wins": len(result[result["profit_abs"] > 0]), + "draws": len(result[result["profit_abs"] == 0]), + "losses": len(result[result["profit_abs"] < 0]), + "winrate": len(result[result["profit_abs"] > 0]) / len(result) if len(result) else 0.0, } -def generate_pair_metrics(pairlist: List[str], stake_currency: str, starting_balance: int, - results: DataFrame, skip_nan: bool = False) -> List[Dict]: +def generate_pair_metrics( + pairlist: List[str], + stake_currency: str, + starting_balance: int, + results: DataFrame, + skip_nan: bool = False, +) -> List[Dict]: """ Generates and returns a list for the given backtest data and the results dataframe :param pairlist: Pairlist used @@ -116,24 +124,23 @@ def generate_pair_metrics(pairlist: List[str], stake_currency: str, starting_bal tabular_data = [] for pair in pairlist: - result = results[results['pair'] == pair] - if skip_nan and result['profit_abs'].isnull().all(): + result = results[results["pair"] == pair] + if skip_nan and result["profit_abs"].isnull().all(): continue tabular_data.append(_generate_result_line(result, starting_balance, pair)) # Sort by total profit %: - tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True) + tabular_data = sorted(tabular_data, key=lambda k: k["profit_total_abs"], reverse=True) # Append Total - tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL')) + tabular_data.append(_generate_result_line(results, starting_balance, "TOTAL")) return tabular_data -def generate_tag_metrics(tag_type: str, - starting_balance: int, - results: DataFrame, - skip_nan: bool = False) -> List[Dict]: +def generate_tag_metrics( + tag_type: str, starting_balance: int, results: DataFrame, skip_nan: bool = False +) -> List[Dict]: """ Generates and returns a list of metrics for the given tag trades and the results dataframe :param starting_balance: Starting balance @@ -147,16 +154,16 @@ def generate_tag_metrics(tag_type: str, if tag_type in results.columns: for tag, count in results[tag_type].value_counts().items(): result = results[results[tag_type] == tag] - if skip_nan and result['profit_abs'].isnull().all(): + if skip_nan and result["profit_abs"].isnull().all(): continue tabular_data.append(_generate_result_line(result, starting_balance, tag)) # Sort by total profit %: - tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True) + tabular_data = sorted(tabular_data, key=lambda k: k["profit_total_abs"], reverse=True) # Append Total - tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL')) + tabular_data.append(_generate_result_line(results, starting_balance, "TOTAL")) return tabular_data else: return [] @@ -171,51 +178,52 @@ def generate_strategy_comparison(bt_stats: Dict) -> List[Dict]: tabular_data = [] for strategy, result in bt_stats.items(): - tabular_data.append(deepcopy(result['results_per_pair'][-1])) + tabular_data.append(deepcopy(result["results_per_pair"][-1])) # Update "key" to strategy (results_per_pair has it as "Total"). - tabular_data[-1]['key'] = strategy - tabular_data[-1]['max_drawdown_account'] = result['max_drawdown_account'] - tabular_data[-1]['max_drawdown_abs'] = fmt_coin( - result['max_drawdown_abs'], result['stake_currency'], False) + tabular_data[-1]["key"] = strategy + tabular_data[-1]["max_drawdown_account"] = result["max_drawdown_account"] + tabular_data[-1]["max_drawdown_abs"] = fmt_coin( + result["max_drawdown_abs"], result["stake_currency"], False + ) return tabular_data def _get_resample_from_period(period: str) -> str: - if period == 'day': - return '1d' - if period == 'week': + if period == "day": + return "1d" + if period == "week": # Weekly defaulting to Monday. - return '1W-MON' - if period == 'month': - return '1ME' + return "1W-MON" + if period == "month": + return "1ME" raise ValueError(f"Period {period} is not supported.") def generate_periodic_breakdown_stats( - trade_list: Union[List, DataFrame], period: str) -> List[Dict[str, Any]]: - + trade_list: Union[List, DataFrame], period: str +) -> List[Dict[str, Any]]: results = trade_list if not isinstance(trade_list, list) else DataFrame.from_records(trade_list) if len(results) == 0: return [] - results['close_date'] = to_datetime(results['close_date'], utc=True) + results["close_date"] = to_datetime(results["close_date"], utc=True) resample_period = _get_resample_from_period(period) - resampled = results.resample(resample_period, on='close_date') + resampled = results.resample(resample_period, on="close_date") stats = [] for name, day in resampled: - profit_abs = day['profit_abs'].sum().round(10) - wins = sum(day['profit_abs'] > 0) - draws = sum(day['profit_abs'] == 0) - loses = sum(day['profit_abs'] < 0) - trades = (wins + draws + loses) + profit_abs = day["profit_abs"].sum().round(10) + wins = sum(day["profit_abs"] > 0) + draws = sum(day["profit_abs"] == 0) + loses = sum(day["profit_abs"] < 0) + trades = wins + draws + loses stats.append( { - 'date': name.strftime('%d/%m/%Y'), - 'date_ts': int(name.to_pydatetime().timestamp() * 1000), - 'profit_abs': profit_abs, - 'wins': wins, - 'draws': draws, - 'loses': loses, - 'winrate': wins / trades if trades else 0.0, + "date": name.strftime("%d/%m/%Y"), + "date_ts": int(name.to_pydatetime().timestamp() * 1000), + "profit_abs": profit_abs, + "wins": wins, + "draws": draws, + "loses": loses, + "winrate": wins / trades if trades else 0.0, } ) return stats @@ -235,74 +243,83 @@ def calc_streak(dataframe: DataFrame) -> Tuple[int, int]: :return: Tuple containing consecutive wins and losses """ - df = Series(np.where(dataframe['profit_ratio'] > 0, 'win', 'loss')).to_frame('result') - df['streaks'] = df['result'].ne(df['result'].shift()).cumsum().rename('streaks') - df['counter'] = df['streaks'].groupby(df['streaks']).cumcount() + 1 - res = df.groupby(df['result']).max() + df = Series(np.where(dataframe["profit_ratio"] > 0, "win", "loss")).to_frame("result") + df["streaks"] = df["result"].ne(df["result"].shift()).cumsum().rename("streaks") + df["counter"] = df["streaks"].groupby(df["streaks"]).cumcount() + 1 + res = df.groupby(df["result"]).max() # - cons_wins = int(res.loc['win', 'counter']) if 'win' in res.index else 0 - cons_losses = int(res.loc['loss', 'counter']) if 'loss' in res.index else 0 + cons_wins = int(res.loc["win", "counter"]) if "win" in res.index else 0 + cons_losses = int(res.loc["loss", "counter"]) if "loss" in res.index else 0 return cons_wins, cons_losses def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: - """ Generate overall trade statistics """ + """Generate overall trade statistics""" if len(results) == 0: return { - 'wins': 0, - 'losses': 0, - 'draws': 0, - 'winrate': 0, - 'holding_avg': timedelta(), - 'winner_holding_avg': timedelta(), - 'loser_holding_avg': timedelta(), - 'max_consecutive_wins': 0, - 'max_consecutive_losses': 0, + "wins": 0, + "losses": 0, + "draws": 0, + "winrate": 0, + "holding_avg": timedelta(), + "winner_holding_avg": timedelta(), + "loser_holding_avg": timedelta(), + "max_consecutive_wins": 0, + "max_consecutive_losses": 0, } - winning_trades = results.loc[results['profit_ratio'] > 0] - draw_trades = results.loc[results['profit_ratio'] == 0] - losing_trades = results.loc[results['profit_ratio'] < 0] + winning_trades = results.loc[results["profit_ratio"] > 0] + draw_trades = results.loc[results["profit_ratio"] == 0] + losing_trades = results.loc[results["profit_ratio"] < 0] - 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()) + 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() + ) winstreak, loss_streak = calc_streak(results) return { - 'wins': len(winning_trades), - 'losses': len(losing_trades), - 'draws': len(draw_trades), - 'winrate': len(winning_trades) / len(results) if len(results) else 0.0, - 'holding_avg': holding_avg, - 'holding_avg_s': holding_avg.total_seconds(), - 'winner_holding_avg': winner_holding_avg, - 'winner_holding_avg_s': winner_holding_avg.total_seconds(), - 'loser_holding_avg': loser_holding_avg, - 'loser_holding_avg_s': loser_holding_avg.total_seconds(), - 'max_consecutive_wins': winstreak, - 'max_consecutive_losses': loss_streak, + "wins": len(winning_trades), + "losses": len(losing_trades), + "draws": len(draw_trades), + "winrate": len(winning_trades) / len(results) if len(results) else 0.0, + "holding_avg": holding_avg, + "holding_avg_s": holding_avg.total_seconds(), + "winner_holding_avg": winner_holding_avg, + "winner_holding_avg_s": winner_holding_avg.total_seconds(), + "loser_holding_avg": loser_holding_avg, + "loser_holding_avg_s": loser_holding_avg.total_seconds(), + "max_consecutive_wins": winstreak, + "max_consecutive_losses": loss_streak, } def generate_daily_stats(results: DataFrame) -> Dict[str, Any]: - """ Generate daily statistics """ + """Generate daily statistics""" if len(results) == 0: return { - 'backtest_best_day': 0, - 'backtest_worst_day': 0, - 'backtest_best_day_abs': 0, - 'backtest_worst_day_abs': 0, - 'winning_days': 0, - 'draw_days': 0, - 'losing_days': 0, - 'daily_profit_list': [], + "backtest_best_day": 0, + "backtest_worst_day": 0, + "backtest_best_day_abs": 0, + "backtest_worst_day_abs": 0, + "winning_days": 0, + "draw_days": 0, + "losing_days": 0, + "daily_profit_list": [], } - 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) + 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) worst_rel = min(daily_profit_rel) best_rel = max(daily_profit_rel) worst = min(daily_profit) @@ -313,24 +330,26 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]: daily_profit_list = [(str(idx.date()), val) for idx, val in daily_profit.items()] return { - 'backtest_best_day': best_rel, - 'backtest_worst_day': worst_rel, - 'backtest_best_day_abs': best, - 'backtest_worst_day_abs': worst, - 'winning_days': winning_days, - 'draw_days': draw_days, - 'losing_days': losing_days, - 'daily_profit': daily_profit_list, + "backtest_best_day": best_rel, + "backtest_worst_day": worst_rel, + "backtest_best_day_abs": best, + "backtest_worst_day_abs": worst, + "winning_days": winning_days, + "draw_days": draw_days, + "losing_days": losing_days, + "daily_profit": daily_profit_list, } -def generate_strategy_stats(pairlist: List[str], - strategy: str, - content: Dict[str, Any], - min_date: datetime, max_date: datetime, - market_change: float, - is_hyperopt: bool = False, - ) -> Dict[str, Any]: +def generate_strategy_stats( + pairlist: List[str], + strategy: str, + content: Dict[str, Any], + min_date: datetime, + max_date: datetime, + market_change: float, + is_hyperopt: bool = False, +) -> Dict[str, Any]: """ :param pairlist: List of pairs to backtest :param strategy: Strategy name @@ -341,175 +360,197 @@ def generate_strategy_stats(pairlist: List[str], :param market_change: float indicating the market change :return: Dictionary containing results per strategy and a strategy summary. """ - results: Dict[str, DataFrame] = content['results'] + results: Dict[str, DataFrame] = content["results"] if not isinstance(results, DataFrame): return {} - config = content['config'] - max_open_trades = min(config['max_open_trades'], len(pairlist)) - start_balance = config['dry_run_wallet'] - stake_currency = config['stake_currency'] + config = content["config"] + max_open_trades = min(config["max_open_trades"], len(pairlist)) + start_balance = config["dry_run_wallet"] + stake_currency = config["stake_currency"] - pair_results = generate_pair_metrics(pairlist, stake_currency=stake_currency, - starting_balance=start_balance, - results=results, skip_nan=False) + pair_results = generate_pair_metrics( + pairlist, + stake_currency=stake_currency, + starting_balance=start_balance, + results=results, + skip_nan=False, + ) - enter_tag_results = generate_tag_metrics("enter_tag", starting_balance=start_balance, - results=results, skip_nan=False) - exit_reason_stats = generate_tag_metrics('exit_reason', starting_balance=start_balance, - results=results, skip_nan=False) + enter_tag_results = generate_tag_metrics( + "enter_tag", starting_balance=start_balance, results=results, skip_nan=False + ) + exit_reason_stats = generate_tag_metrics( + "exit_reason", starting_balance=start_balance, results=results, skip_nan=False + ) left_open_results = generate_pair_metrics( - pairlist, stake_currency=stake_currency, starting_balance=start_balance, - results=results.loc[results['exit_reason'] == 'force_exit'], skip_nan=True) + pairlist, + stake_currency=stake_currency, + starting_balance=start_balance, + results=results.loc[results["exit_reason"] == "force_exit"], + skip_nan=True, + ) daily_stats = generate_daily_stats(results) trade_stats = generate_trading_stats(results) periodic_breakdown = {} if not is_hyperopt: - periodic_breakdown = {'periodic_breakdown': generate_all_periodic_breakdown_stats(results)} + periodic_breakdown = {"periodic_breakdown": generate_all_periodic_breakdown_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 - winning_profit = results.loc[results['profit_abs'] > 0, 'profit_abs'].sum() - losing_profit = results.loc[results['profit_abs'] < 0, 'profit_abs'].sum() + 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 + ) + winning_profit = results.loc[results["profit_abs"] > 0, "profit_abs"].sum() + losing_profit = results.loc[results["profit_abs"] < 0, "profit_abs"].sum() profit_factor = winning_profit / abs(losing_profit) if losing_profit else 0.0 expectancy, expectancy_ratio = calculate_expectancy(results) backtest_days = (max_date - min_date).days or 1 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, - 'results_per_enter_tag': enter_tag_results, - 'exit_reason_summary': exit_reason_stats, - 'left_open_trades': left_open_results, - - 'total_trades': len(results), - 'trade_count_long': len(results.loc[~results['is_short']]), - 'trade_count_short': len(results.loc[results['is_short']]), - '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() / start_balance, - 'profit_total_long': results.loc[~results['is_short'], 'profit_abs'].sum() / start_balance, - 'profit_total_short': results.loc[results['is_short'], 'profit_abs'].sum() / start_balance, - 'profit_total_abs': results['profit_abs'].sum(), - 'profit_total_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(), - 'profit_total_short_abs': results.loc[results['is_short'], 'profit_abs'].sum(), - 'cagr': calculate_cagr(backtest_days, start_balance, content['final_balance']), - 'expectancy': expectancy, - 'expectancy_ratio': expectancy_ratio, - 'sortino': calculate_sortino(results, min_date, max_date, start_balance), - 'sharpe': calculate_sharpe(results, min_date, max_date, start_balance), - 'calmar': calculate_calmar(results, min_date, max_date, start_balance), - 'profit_factor': profit_factor, - '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), - 'market_change': market_change, - 'pairlist': pairlist, - 'stake_amount': config['stake_amount'], - 'stake_currency': config['stake_currency'], - 'stake_currency_decimals': decimals_per_coin(config['stake_currency']), - 'starting_balance': start_balance, - 'dry_run_wallet': start_balance, - 'final_balance': content['final_balance'], - 'rejected_signals': content['rejected_signals'], - 'timedout_entry_orders': content['timedout_entry_orders'], - 'timedout_exit_orders': content['timedout_exit_orders'], - 'canceled_trade_entries': content['canceled_trade_entries'], - 'canceled_entry_orders': content['canceled_entry_orders'], - 'replaced_entry_orders': content['replaced_entry_orders'], - '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'], - 'timeframe_detail': config.get('timeframe_detail', ''), - 'timerange': config.get('timerange', ''), - 'enable_protections': config.get('enable_protections', False), - 'strategy_name': strategy, + "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, + "results_per_enter_tag": enter_tag_results, + "exit_reason_summary": exit_reason_stats, + "left_open_trades": left_open_results, + "total_trades": len(results), + "trade_count_long": len(results.loc[~results["is_short"]]), + "trade_count_short": len(results.loc[results["is_short"]]), + "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() / start_balance, + "profit_total_long": results.loc[~results["is_short"], "profit_abs"].sum() / start_balance, + "profit_total_short": results.loc[results["is_short"], "profit_abs"].sum() / start_balance, + "profit_total_abs": results["profit_abs"].sum(), + "profit_total_long_abs": results.loc[~results["is_short"], "profit_abs"].sum(), + "profit_total_short_abs": results.loc[results["is_short"], "profit_abs"].sum(), + "cagr": calculate_cagr(backtest_days, start_balance, content["final_balance"]), + "expectancy": expectancy, + "expectancy_ratio": expectancy_ratio, + "sortino": calculate_sortino(results, min_date, max_date, start_balance), + "sharpe": calculate_sharpe(results, min_date, max_date, start_balance), + "calmar": calculate_calmar(results, min_date, max_date, start_balance), + "profit_factor": profit_factor, + "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), + "market_change": market_change, + "pairlist": pairlist, + "stake_amount": config["stake_amount"], + "stake_currency": config["stake_currency"], + "stake_currency_decimals": decimals_per_coin(config["stake_currency"]), + "starting_balance": start_balance, + "dry_run_wallet": start_balance, + "final_balance": content["final_balance"], + "rejected_signals": content["rejected_signals"], + "timedout_entry_orders": content["timedout_entry_orders"], + "timedout_exit_orders": content["timedout_exit_orders"], + "canceled_trade_entries": content["canceled_trade_entries"], + "canceled_entry_orders": content["canceled_entry_orders"], + "replaced_entry_orders": content["replaced_entry_orders"], + "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"], + "timeframe_detail": config.get("timeframe_detail", ""), + "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_exit_signal': config['use_exit_signal'], - 'exit_profit_only': config['exit_profit_only'], - 'exit_profit_offset': config['exit_profit_offset'], - 'ignore_roi_if_entry_signal': config['ignore_roi_if_entry_signal'], + "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_exit_signal": config["use_exit_signal"], + "exit_profit_only": config["exit_profit_only"], + "exit_profit_offset": config["exit_profit_offset"], + "ignore_roi_if_entry_signal": config["ignore_roi_if_entry_signal"], **periodic_breakdown, **daily_stats, - **trade_stats + **trade_stats, } try: max_drawdown_legacy, _, _, _, _, _ = calculate_max_drawdown( - results, value_col='profit_ratio') - (drawdown_abs, drawdown_start, drawdown_end, high_val, low_val, - max_drawdown) = calculate_max_drawdown( - results, value_col='profit_abs', starting_balance=start_balance) + results, value_col="profit_ratio" + ) + (drawdown_abs, drawdown_start, drawdown_end, high_val, low_val, max_drawdown) = ( + calculate_max_drawdown(results, value_col="profit_abs", starting_balance=start_balance) + ) # max_relative_drawdown = Underwater (_, _, _, _, _, max_relative_drawdown) = calculate_max_drawdown( - results, value_col='profit_abs', starting_balance=start_balance, relative=True) + results, value_col="profit_abs", starting_balance=start_balance, relative=True + ) - strat_stats.update({ - 'max_drawdown': max_drawdown_legacy, # Deprecated - do not use - 'max_drawdown_account': max_drawdown, - 'max_relative_drawdown': max_relative_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, - }) + strat_stats.update( + { + "max_drawdown": max_drawdown_legacy, # Deprecated - do not use + "max_drawdown_account": max_drawdown, + "max_relative_drawdown": max_relative_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, start_balance) - strat_stats.update({ - 'csum_min': csum_min, - 'csum_max': csum_max - }) + strat_stats.update({"csum_min": csum_min, "csum_max": csum_max}) except ValueError: - strat_stats.update({ - 'max_drawdown': 0.0, - 'max_drawdown_account': 0.0, - 'max_relative_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 - }) + strat_stats.update( + { + "max_drawdown": 0.0, + "max_drawdown_account": 0.0, + "max_relative_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: datetime, max_date: datetime - ) -> BacktestResultType: +def generate_backtest_stats( + btdata: Dict[str, DataFrame], + all_results: Dict[str, Dict[str, Union[DataFrame, Dict]]], + min_date: datetime, + max_date: datetime, +) -> BacktestResultType: """ :param btdata: Backtest data :param all_results: backtest result - dictionary in the form: @@ -519,29 +560,30 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame], :return: Dictionary containing results per strategy and a strategy summary. """ result: BacktestResultType = { - 'metadata': {}, - 'strategy': {}, - 'strategy_comparison': [], + "metadata": {}, + "strategy": {}, + "strategy_comparison": [], } - market_change = calculate_market_change(btdata, 'close') + market_change = calculate_market_change(btdata, "close") metadata = {} pairlist = list(btdata.keys()) for strategy, content in all_results.items(): - strat_stats = generate_strategy_stats(pairlist, strategy, content, - min_date, max_date, market_change=market_change) + strat_stats = generate_strategy_stats( + pairlist, strategy, content, min_date, max_date, market_change=market_change + ) metadata[strategy] = { - 'run_id': content['run_id'], - 'backtest_start_time': content['backtest_start_time'], - 'timeframe': content['config']['timeframe'], - 'timeframe_detail': content['config'].get('timeframe_detail', None), - 'backtest_start_ts': int(min_date.timestamp()), - 'backtest_end_ts': int(max_date.timestamp()), + "run_id": content["run_id"], + "backtest_start_time": content["backtest_start_time"], + "timeframe": content["config"]["timeframe"], + "timeframe_detail": content["config"].get("timeframe_detail", None), + "backtest_start_ts": int(min_date.timestamp()), + "backtest_end_ts": int(max_date.timestamp()), } - result['strategy'][strategy] = strat_stats + result["strategy"][strategy] = strat_stats - strategy_results = generate_strategy_comparison(bt_stats=result['strategy']) + strategy_results = generate_strategy_comparison(bt_stats=result["strategy"]) - result['metadata'] = metadata - result['strategy_comparison'] = strategy_results + result["metadata"] = metadata + result["strategy_comparison"] = strategy_results return result diff --git a/freqtrade/optimize/space/decimalspace.py b/freqtrade/optimize/space/decimalspace.py index 61aad0597..f5c122fb3 100644 --- a/freqtrade/optimize/space/decimalspace.py +++ b/freqtrade/optimize/space/decimalspace.py @@ -3,9 +3,17 @@ from skopt.space import Integer class SKDecimal(Integer): - - def __init__(self, low, high, decimals=3, prior="uniform", base=10, transform=None, - name=None, dtype=np.int64): + def __init__( + self, + low, + high, + decimals=3, + prior="uniform", + base=10, + transform=None, + name=None, + dtype=np.int64, + ): self.decimals = decimals self.pow_dot_one = pow(0.1, self.decimals)