diff --git a/docs/advanced-backtesting.md b/docs/advanced-backtesting.md index 563e5df08..b97db79c5 100644 --- a/docs/advanced-backtesting.md +++ b/docs/advanced-backtesting.md @@ -18,15 +18,13 @@ freqtrade backtesting -c --timeframe --strategy Dict[str, Dict[str, pd.DataFrame]]: + return _load_backtest_analysis_data(backtest_dir, "exited") + + +def _process_candles_and_indicators( + pairlist, strategy_name, trades, signal_candles, date_col: str = "open_date" +): + analysed_trades_dict: Dict[str, Dict] = {strategy_name: {}} try: logger.info(f"Processing {strategy_name} : {len(pairlist)} pairs") @@ -57,7 +63,7 @@ def _process_candles_and_indicators(pairlist, strategy_name, trades, signal_cand for pair in pairlist: if pair in signal_candles[strategy_name]: analysed_trades_dict[strategy_name][pair] = _analyze_candles_and_indicators( - pair, trades, signal_candles[strategy_name][pair] + pair, trades, signal_candles[strategy_name][pair], date_col ) except Exception as e: print(f"Cannot process entry/exit reasons for {strategy_name}: ", e) @@ -65,7 +71,9 @@ def _process_candles_and_indicators(pairlist, strategy_name, trades, signal_cand return analysed_trades_dict -def _analyze_candles_and_indicators(pair, trades: pd.DataFrame, signal_candles: pd.DataFrame): +def _analyze_candles_and_indicators( + pair: str, trades: pd.DataFrame, signal_candles: pd.DataFrame, date_col: str = "open_date" +) -> pd.DataFrame: buyf = signal_candles if len(buyf) > 0: @@ -75,8 +83,8 @@ def _analyze_candles_and_indicators(pair, trades: pd.DataFrame, signal_candles: trades_inds = pd.DataFrame() if trades_red.shape[0] > 0 and buyf.shape[0] > 0: - for t, v in trades_red.open_date.items(): - allinds = buyf.loc[(buyf["date"] < v)] + for t, v in trades_red.iterrows(): + allinds = buyf.loc[(buyf["date"] < v[date_col])] if allinds.shape[0] > 0: tmp_inds = allinds.iloc[[-1]] @@ -235,7 +243,7 @@ def _select_rows_by_tags(df, enter_reason_list, exit_reason_list): def prepare_results( analysed_trades, stratname, enter_reason_list, exit_reason_list, timerange=None -): +) -> pd.DataFrame: res_df = pd.DataFrame() for pair, trades in analysed_trades[stratname].items(): if trades.shape[0] > 0: @@ -252,6 +260,7 @@ def prepare_results( def print_results( res_df: pd.DataFrame, + exit_df: pd.DataFrame, analysis_groups: List[str], indicator_list: List[str], csv_path: Path, @@ -278,9 +287,11 @@ def print_results( for ind in indicator_list: if ind in res_df: available_inds.append(ind) - ilist = ["pair", "enter_reason", "exit_reason"] + available_inds + + merged_df = _merge_dfs(res_df, exit_df, available_inds) + _print_table( - res_df[ilist], + merged_df, sortcols=["exit_reason"], show_index=False, name="Indicators:", @@ -291,6 +302,22 @@ def print_results( print("\\No trades to show") +def _merge_dfs(entry_df, exit_df, available_inds): + merge_on = ["pair", "open_date"] + signal_wide_indicators = list(set(available_inds) - set(BT_DATA_COLUMNS)) + columns_to_keep = merge_on + ["enter_reason", "exit_reason"] + available_inds + + if exit_df is None or exit_df.empty: + return entry_df[columns_to_keep] + + return pd.merge( + entry_df[columns_to_keep], + exit_df[merge_on + signal_wide_indicators], + on=merge_on, + suffixes=(" (entry)", " (exit)"), + ) + + def _print_table( df: pd.DataFrame, sortcols=None, *, show_index=False, name=None, to_csv=False, csv_path: Path ): @@ -333,6 +360,7 @@ def process_entry_exit_reasons(config: Config): if trades is not None and not trades.empty: signal_candles = _load_signal_candles(config["exportfilename"]) + exit_signals = _load_exit_signal_candles(config["exportfilename"]) rej_df = None if do_rejected: @@ -345,20 +373,31 @@ def process_entry_exit_reasons(config: Config): timerange=timerange, ) - analysed_trades_dict = _process_candles_and_indicators( - config["exchange"]["pair_whitelist"], strategy_name, trades, signal_candles - ) - - res_df = prepare_results( - analysed_trades_dict, - strategy_name, + entry_df = _generate_dfs( + config["exchange"]["pair_whitelist"], enter_reason_list, exit_reason_list, - timerange=timerange, + signal_candles, + strategy_name, + timerange, + trades, + "open_date", + ) + + exit_df = _generate_dfs( + config["exchange"]["pair_whitelist"], + enter_reason_list, + exit_reason_list, + exit_signals, + strategy_name, + timerange, + trades, + "close_date", ) print_results( - res_df, + entry_df, + exit_df, analysis_groups, indicator_list, rejected_signals=rej_df, @@ -368,3 +407,30 @@ def process_entry_exit_reasons(config: Config): except ValueError as e: raise OperationalException(e) from e + + +def _generate_dfs( + pairlist: list, + enter_reason_list: list, + exit_reason_list: list, + signal_candles: Dict, + strategy_name: str, + timerange: TimeRange, + trades: pd.DataFrame, + date_col: str, +) -> pd.DataFrame: + analysed_trades_dict = _process_candles_and_indicators( + pairlist, + strategy_name, + trades, + signal_candles, + date_col, + ) + res_df = prepare_results( + analysed_trades_dict, + strategy_name, + enter_reason_list, + exit_reason_list, + timerange=timerange, + ) + return res_df diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 828908a4c..71adfbb4b 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -122,6 +122,7 @@ class Backtesting: self.processed_dfs: Dict[str, Dict] = {} self.rejected_dict: Dict[str, List] = {} self.rejected_df: Dict[str, Dict] = {} + self.exited_dfs: Dict[str, Dict] = {} self._exchange_name = self.config["exchange"]["name"] if not exchange: @@ -1564,11 +1565,14 @@ class Backtesting: and self.dataprovider.runmode == RunMode.BACKTEST ): self.processed_dfs[strategy_name] = generate_trade_signal_candles( - preprocessed_tmp, results + preprocessed_tmp, results, "open_date" ) self.rejected_df[strategy_name] = generate_rejected_signals( preprocessed_tmp, self.rejected_dict ) + self.exited_dfs[strategy_name] = generate_trade_signal_candles( + preprocessed_tmp, results, "close_date" + ) return min_date, max_date @@ -1644,7 +1648,11 @@ class Backtesting: 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, + self.exited_dfs, + dt_appendix, ) # Results may be mixed up now. Sort them so they follow --strategy-list order. diff --git a/freqtrade/optimize/optimize_reports/bt_storage.py b/freqtrade/optimize/optimize_reports/bt_storage.py index 2953c65e7..9766bcd98 100644 --- a/freqtrade/optimize/optimize_reports/bt_storage.py +++ b/freqtrade/optimize/optimize_reports/bt_storage.py @@ -90,7 +90,12 @@ def _store_backtest_analysis_data( def store_backtest_analysis_results( - recordfilename: Path, candles: Dict[str, Dict], trades: Dict[str, Dict], dtappendix: str + recordfilename: Path, + candles: Dict[str, Dict], + trades: Dict[str, Dict], + exited: Dict[str, Dict], + dtappendix: str, ) -> None: _store_backtest_analysis_data(recordfilename, candles, dtappendix, "signals") _store_backtest_analysis_data(recordfilename, trades, dtappendix, "rejected") + _store_backtest_analysis_data(recordfilename, exited, dtappendix, "exited") diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py index c3bb607dd..1168f0c2f 100644 --- a/freqtrade/optimize/optimize_reports/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -25,8 +25,8 @@ logger = logging.getLogger(__name__) def generate_trade_signal_candles( - preprocessed_df: Dict[str, DataFrame], bt_results: Dict[str, Any] -) -> DataFrame: + preprocessed_df: Dict[str, DataFrame], bt_results: Dict[str, Any], date_col: str +) -> Dict[str, DataFrame]: signal_candles_only = {} for pair in preprocessed_df.keys(): signal_candles_only_df = DataFrame() @@ -36,8 +36,8 @@ def generate_trade_signal_candles( 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)] + for t, v in pairresults.iterrows(): + allinds = pairdf.loc[(pairdf["date"] < v[date_col])] signal_inds = allinds.iloc[[-1]] signal_candles_only_df = concat( [signal_candles_only_df.infer_objects(), signal_inds.infer_objects()] diff --git a/tests/data/test_entryexitanalysis.py b/tests/data/test_entryexitanalysis.py index e7909c339..374b84fc7 100644 --- a/tests/data/test_entryexitanalysis.py +++ b/tests/data/test_entryexitanalysis.py @@ -18,7 +18,9 @@ def entryexitanalysis_cleanup() -> None: Backtesting.cleanup() -def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, user_dir, capsys): +def test_backtest_analysis_on_entry_and_rejected_signals_nomock( + default_conf, mocker, caplog, testdatadir, user_dir, capsys +): caplog.set_level(logging.INFO) (user_dir / "backtest_results").mkdir(parents=True, exist_ok=True) @@ -158,6 +160,15 @@ def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, use assert "34.049" in captured.out assert "0.104" in captured.out assert "52.829" in captured.out + # assert indicator list + assert "close (entry)" in captured.out + assert "0.016" in captured.out + assert "rsi (entry)" in captured.out + assert "54.320" in captured.out + assert "close (exit)" in captured.out + assert "rsi (exit)" in captured.out + assert "52.829" in captured.out + assert "profit_abs" in captured.out # test group 1 args = get_args(base_args + ["--analysis-groups", "1"]) diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 4c7ce06e8..40673a1b6 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -293,20 +293,25 @@ def test_store_backtest_candles(testdatadir, mocker): candle_dict = {"DefStrat": {"UNITTEST/BTC": pd.DataFrame()}} # mock directory exporting - store_backtest_analysis_results(testdatadir, candle_dict, {}, "2022_01_01_15_05_13") + store_backtest_analysis_results(testdatadir, candle_dict, {}, {}, "2022_01_01_15_05_13") - assert dump_mock.call_count == 2 + assert dump_mock.call_count == 3 assert isinstance(dump_mock.call_args_list[0][0][0], Path) assert str(dump_mock.call_args_list[0][0][0]).endswith("_signals.pkl") + assert str(dump_mock.call_args_list[1][0][0]).endswith("_rejected.pkl") + assert str(dump_mock.call_args_list[2][0][0]).endswith("_exited.pkl") dump_mock.reset_mock() # mock file exporting filename = Path(testdatadir / "testresult") - store_backtest_analysis_results(filename, candle_dict, {}, "2022_01_01_15_05_13") - assert dump_mock.call_count == 2 + store_backtest_analysis_results(filename, candle_dict, {}, {}, "2022_01_01_15_05_13") + assert dump_mock.call_count == 3 assert isinstance(dump_mock.call_args_list[0][0][0], Path) # result will be testdatadir / testresult-_signals.pkl assert str(dump_mock.call_args_list[0][0][0]).endswith("_signals.pkl") + assert str(dump_mock.call_args_list[1][0][0]).endswith("_rejected.pkl") + assert str(dump_mock.call_args_list[2][0][0]).endswith("_exited.pkl") + dump_mock.reset_mock() @@ -315,7 +320,7 @@ def test_write_read_backtest_candles(tmp_path): # test directory exporting sample_date = "2022_01_01_15_05_13" - store_backtest_analysis_results(tmp_path, candle_dict, {}, sample_date) + store_backtest_analysis_results(tmp_path, candle_dict, {}, {}, sample_date) stored_file = tmp_path / f"backtest-result-{sample_date}_signals.pkl" with stored_file.open("rb") as scp: pickled_signal_candles = joblib.load(scp) @@ -330,7 +335,7 @@ def test_write_read_backtest_candles(tmp_path): # test file exporting filename = tmp_path / "testresult" - store_backtest_analysis_results(filename, candle_dict, {}, sample_date) + store_backtest_analysis_results(filename, candle_dict, {}, {}, sample_date) stored_file = tmp_path / f"testresult-{sample_date}_signals.pkl" with stored_file.open("rb") as scp: pickled_signal_candles = joblib.load(scp)