Merge pull request #10485 from jainanuj94/feature/8902

Add exit signals to export in backtesting
This commit is contained in:
Matthias 2024-09-07 20:12:05 +02:00 committed by GitHub
commit 611a3ce138
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 164 additions and 39 deletions

View File

@ -18,15 +18,13 @@ freqtrade backtesting -c <config.json> --timeframe <tf> --strategy <strategy_nam
``` ```
This will tell freqtrade to output a pickled dictionary of strategy, pairs and corresponding This will tell freqtrade to output a pickled dictionary of strategy, pairs and corresponding
DataFrame of the candles that resulted in buy signals. Depending on how many buys your strategy DataFrame of the candles that resulted in entry and exit signals.
makes, this file may get quite large, so periodically check your `user_data/backtest_results` Depending on how many entries your strategy makes, this file may get quite large, so periodically check your `user_data/backtest_results` folder to delete old exports.
folder to delete old exports.
Before running your next backtest, make sure you either delete your old backtest results or run Before running your next backtest, make sure you either delete your old backtest results or run
backtesting with the `--cache none` option to make sure no cached results are used. backtesting with the `--cache none` option to make sure no cached results are used.
If all goes well, you should now see a `backtest-result-{timestamp}_signals.pkl` file in the If all goes well, you should now see a `backtest-result-{timestamp}_signals.pkl` and `backtest-result-{timestamp}_exited.pkl` files in the `user_data/backtest_results` folder.
`user_data/backtest_results` folder.
To analyze the entry/exit tags, we now need to use the `freqtrade backtesting-analysis` command To analyze the entry/exit tags, we now need to use the `freqtrade backtesting-analysis` command
with `--analysis-groups` option provided with space-separated arguments: with `--analysis-groups` option provided with space-separated arguments:
@ -103,6 +101,10 @@ The indicators have to be present in your strategy's main DataFrame (either for
timeframe or for informative timeframes) otherwise they will simply be ignored in the script timeframe or for informative timeframes) otherwise they will simply be ignored in the script
output. output.
!!! Note "Indicator List"
The indicator values will be displayed for both entry and exit points. If `--indicator-list all` is specified,
only the indicators at the entry point will be shown to avoid excessively large lists, which could occur depending on the strategy.
There are a range of candle and trade-related fields that are included in the analysis so are There are a range of candle and trade-related fields that are included in the analysis so are
automatically accessible by including them on the indicator-list, and these include: automatically accessible by including them on the indicator-list, and these include:
@ -118,6 +120,34 @@ automatically accessible by including them on the indicator-list, and these incl
- **profit_ratio :** trade profit ratio - **profit_ratio :** trade profit ratio
- **profit_abs :** absolute profit return of the trade - **profit_abs :** absolute profit return of the trade
#### Sample Output for Indicator Values
```bash
freqtrade backtesting-analysis -c user_data/config.json --analysis-groups 0 --indicator-list chikou_span tenkan_sen
```
In this example,
we aim to display the `chikou_span` and `tenkan_sen` indicator values at both the entry and exit points of trades.
A sample output for indicators might look like this:
| pair | open_date | enter_reason | exit_reason | chikou_span (entry) | tenkan_sen (entry) | chikou_span (exit) | tenkan_sen (exit) |
|-----------|---------------------------|--------------|-------------|---------------------|--------------------|--------------------|-------------------|
| DOGE/USDT | 2024-07-06 00:35:00+00:00 | | exit_signal | 0.105 | 0.106 | 0.105 | 0.107 |
| BTC/USDT | 2024-08-05 14:20:00+00:00 | | roi | 54643.440 | 51696.400 | 54386.000 | 52072.010 |
As shown in the table, `chikou_span (entry)` represents the indicator value at the time of trade entry,
while `chikou_span (exit)` reflects its value at the time of exit.
This detailed view of indicator values enhances the analysis.
The `(entry)` and `(exit)` suffixes are added to indicators
to distinguish the values at the entry and exit points of the trade.
!!! Note "Trade-wide Indicators"
Certain trade-wide indicators do not have the `(entry)` or `(exit)` suffix. These indicators include: `pair`, `stake_amount`,
`max_stake_amount`, `amount`, `open_date`, `close_date`, `open_rate`, `close_rate`, `fee_open`, `fee_close`, `trade_duration`,
`profit_ratio`, `profit_abs`, `exit_reason`,`initial_stop_loss_abs`, `initial_stop_loss_ratio`, `stop_loss_abs`, `stop_loss_ratio`,
`min_rate`, `max_rate`, `is_open`, `enter_tag`, `leverage`, `is_short`, `open_timestamp`, `close_timestamp` and `orders`
### Filtering the trade output by date ### Filtering the trade output by date

View File

@ -1,6 +1,6 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import List from typing import Dict, List
import joblib import joblib
import pandas as pd import pandas as pd
@ -8,6 +8,7 @@ import pandas as pd
from freqtrade.configuration import TimeRange from freqtrade.configuration import TimeRange
from freqtrade.constants import Config from freqtrade.constants import Config
from freqtrade.data.btanalysis import ( from freqtrade.data.btanalysis import (
BT_DATA_COLUMNS,
get_latest_backtest_filename, get_latest_backtest_filename,
load_backtest_data, load_backtest_data,
load_backtest_stats, load_backtest_stats,
@ -47,9 +48,14 @@ def _load_signal_candles(backtest_dir: Path):
return _load_backtest_analysis_data(backtest_dir, "signals") return _load_backtest_analysis_data(backtest_dir, "signals")
def _process_candles_and_indicators(pairlist, strategy_name, trades, signal_candles): def _load_exit_signal_candles(backtest_dir: Path) -> Dict[str, Dict[str, pd.DataFrame]]:
analysed_trades_dict = {} return _load_backtest_analysis_data(backtest_dir, "exited")
analysed_trades_dict[strategy_name] = {}
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: try:
logger.info(f"Processing {strategy_name} : {len(pairlist)} pairs") 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: for pair in pairlist:
if pair in signal_candles[strategy_name]: if pair in signal_candles[strategy_name]:
analysed_trades_dict[strategy_name][pair] = _analyze_candles_and_indicators( 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: except Exception as e:
print(f"Cannot process entry/exit reasons for {strategy_name}: ", 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 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 buyf = signal_candles
if len(buyf) > 0: if len(buyf) > 0:
@ -75,8 +83,8 @@ def _analyze_candles_and_indicators(pair, trades: pd.DataFrame, signal_candles:
trades_inds = pd.DataFrame() trades_inds = pd.DataFrame()
if trades_red.shape[0] > 0 and buyf.shape[0] > 0: if trades_red.shape[0] > 0 and buyf.shape[0] > 0:
for t, v in trades_red.open_date.items(): for t, v in trades_red.iterrows():
allinds = buyf.loc[(buyf["date"] < v)] allinds = buyf.loc[(buyf["date"] < v[date_col])]
if allinds.shape[0] > 0: if allinds.shape[0] > 0:
tmp_inds = allinds.iloc[[-1]] tmp_inds = allinds.iloc[[-1]]
@ -235,7 +243,7 @@ def _select_rows_by_tags(df, enter_reason_list, exit_reason_list):
def prepare_results( def prepare_results(
analysed_trades, stratname, enter_reason_list, exit_reason_list, timerange=None analysed_trades, stratname, enter_reason_list, exit_reason_list, timerange=None
): ) -> pd.DataFrame:
res_df = pd.DataFrame() res_df = pd.DataFrame()
for pair, trades in analysed_trades[stratname].items(): for pair, trades in analysed_trades[stratname].items():
if trades.shape[0] > 0: if trades.shape[0] > 0:
@ -252,6 +260,7 @@ def prepare_results(
def print_results( def print_results(
res_df: pd.DataFrame, res_df: pd.DataFrame,
exit_df: pd.DataFrame,
analysis_groups: List[str], analysis_groups: List[str],
indicator_list: List[str], indicator_list: List[str],
csv_path: Path, csv_path: Path,
@ -278,9 +287,11 @@ def print_results(
for ind in indicator_list: for ind in indicator_list:
if ind in res_df: if ind in res_df:
available_inds.append(ind) available_inds.append(ind)
ilist = ["pair", "enter_reason", "exit_reason"] + available_inds
merged_df = _merge_dfs(res_df, exit_df, available_inds)
_print_table( _print_table(
res_df[ilist], merged_df,
sortcols=["exit_reason"], sortcols=["exit_reason"],
show_index=False, show_index=False,
name="Indicators:", name="Indicators:",
@ -291,6 +302,22 @@ def print_results(
print("\\No trades to show") 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( def _print_table(
df: pd.DataFrame, sortcols=None, *, show_index=False, name=None, to_csv=False, csv_path: Path 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: if trades is not None and not trades.empty:
signal_candles = _load_signal_candles(config["exportfilename"]) signal_candles = _load_signal_candles(config["exportfilename"])
exit_signals = _load_exit_signal_candles(config["exportfilename"])
rej_df = None rej_df = None
if do_rejected: if do_rejected:
@ -345,20 +373,31 @@ def process_entry_exit_reasons(config: Config):
timerange=timerange, timerange=timerange,
) )
analysed_trades_dict = _process_candles_and_indicators( entry_df = _generate_dfs(
config["exchange"]["pair_whitelist"], strategy_name, trades, signal_candles config["exchange"]["pair_whitelist"],
)
res_df = prepare_results(
analysed_trades_dict,
strategy_name,
enter_reason_list, enter_reason_list,
exit_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( print_results(
res_df, entry_df,
exit_df,
analysis_groups, analysis_groups,
indicator_list, indicator_list,
rejected_signals=rej_df, rejected_signals=rej_df,
@ -368,3 +407,30 @@ def process_entry_exit_reasons(config: Config):
except ValueError as e: except ValueError as e:
raise OperationalException(e) from 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

View File

@ -122,6 +122,7 @@ class Backtesting:
self.processed_dfs: Dict[str, Dict] = {} self.processed_dfs: Dict[str, Dict] = {}
self.rejected_dict: Dict[str, List] = {} self.rejected_dict: Dict[str, List] = {}
self.rejected_df: Dict[str, Dict] = {} self.rejected_df: Dict[str, Dict] = {}
self.exited_dfs: Dict[str, Dict] = {}
self._exchange_name = self.config["exchange"]["name"] self._exchange_name = self.config["exchange"]["name"]
if not exchange: if not exchange:
@ -1564,11 +1565,14 @@ class Backtesting:
and self.dataprovider.runmode == RunMode.BACKTEST and self.dataprovider.runmode == RunMode.BACKTEST
): ):
self.processed_dfs[strategy_name] = generate_trade_signal_candles( 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( self.rejected_df[strategy_name] = generate_rejected_signals(
preprocessed_tmp, self.rejected_dict preprocessed_tmp, self.rejected_dict
) )
self.exited_dfs[strategy_name] = generate_trade_signal_candles(
preprocessed_tmp, results, "close_date"
)
return min_date, max_date return min_date, max_date
@ -1644,7 +1648,11 @@ class Backtesting:
and self.dataprovider.runmode == RunMode.BACKTEST and self.dataprovider.runmode == RunMode.BACKTEST
): ):
store_backtest_analysis_results( 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. # Results may be mixed up now. Sort them so they follow --strategy-list order.

View File

@ -90,7 +90,12 @@ def _store_backtest_analysis_data(
def store_backtest_analysis_results( 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: ) -> None:
_store_backtest_analysis_data(recordfilename, candles, dtappendix, "signals") _store_backtest_analysis_data(recordfilename, candles, dtappendix, "signals")
_store_backtest_analysis_data(recordfilename, trades, dtappendix, "rejected") _store_backtest_analysis_data(recordfilename, trades, dtappendix, "rejected")
_store_backtest_analysis_data(recordfilename, exited, dtappendix, "exited")

View File

@ -25,8 +25,8 @@ logger = logging.getLogger(__name__)
def generate_trade_signal_candles( def generate_trade_signal_candles(
preprocessed_df: Dict[str, DataFrame], bt_results: Dict[str, Any] preprocessed_df: Dict[str, DataFrame], bt_results: Dict[str, Any], date_col: str
) -> DataFrame: ) -> Dict[str, DataFrame]:
signal_candles_only = {} signal_candles_only = {}
for pair in preprocessed_df.keys(): for pair in preprocessed_df.keys():
signal_candles_only_df = DataFrame() signal_candles_only_df = DataFrame()
@ -36,8 +36,8 @@ def generate_trade_signal_candles(
pairresults = resdf.loc[(resdf["pair"] == pair)] pairresults = resdf.loc[(resdf["pair"] == pair)]
if pairdf.shape[0] > 0: if pairdf.shape[0] > 0:
for t, v in pairresults.open_date.items(): for t, v in pairresults.iterrows():
allinds = pairdf.loc[(pairdf["date"] < v)] allinds = pairdf.loc[(pairdf["date"] < v[date_col])]
signal_inds = allinds.iloc[[-1]] signal_inds = allinds.iloc[[-1]]
signal_candles_only_df = concat( signal_candles_only_df = concat(
[signal_candles_only_df.infer_objects(), signal_inds.infer_objects()] [signal_candles_only_df.infer_objects(), signal_inds.infer_objects()]

View File

@ -18,7 +18,9 @@ def entryexitanalysis_cleanup() -> None:
Backtesting.cleanup() 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) caplog.set_level(logging.INFO)
(user_dir / "backtest_results").mkdir(parents=True, exist_ok=True) (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 "34.049" in captured.out
assert "0.104" in captured.out assert "0.104" in captured.out
assert "52.829" 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 # test group 1
args = get_args(base_args + ["--analysis-groups", "1"]) args = get_args(base_args + ["--analysis-groups", "1"])

View File

@ -293,20 +293,25 @@ def test_store_backtest_candles(testdatadir, mocker):
candle_dict = {"DefStrat": {"UNITTEST/BTC": pd.DataFrame()}} candle_dict = {"DefStrat": {"UNITTEST/BTC": pd.DataFrame()}}
# mock directory exporting # 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 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[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() dump_mock.reset_mock()
# mock file exporting # mock file exporting
filename = Path(testdatadir / "testresult") filename = Path(testdatadir / "testresult")
store_backtest_analysis_results(filename, candle_dict, {}, "2022_01_01_15_05_13") store_backtest_analysis_results(filename, 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 isinstance(dump_mock.call_args_list[0][0][0], Path)
# result will be testdatadir / testresult-<timestamp>_signals.pkl # result will be testdatadir / testresult-<timestamp>_signals.pkl
assert str(dump_mock.call_args_list[0][0][0]).endswith("_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() dump_mock.reset_mock()
@ -315,7 +320,7 @@ def test_write_read_backtest_candles(tmp_path):
# test directory exporting # test directory exporting
sample_date = "2022_01_01_15_05_13" 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" stored_file = tmp_path / f"backtest-result-{sample_date}_signals.pkl"
with stored_file.open("rb") as scp: with stored_file.open("rb") as scp:
pickled_signal_candles = joblib.load(scp) pickled_signal_candles = joblib.load(scp)
@ -330,7 +335,7 @@ def test_write_read_backtest_candles(tmp_path):
# test file exporting # test file exporting
filename = tmp_path / "testresult" 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" stored_file = tmp_path / f"testresult-{sample_date}_signals.pkl"
with stored_file.open("rb") as scp: with stored_file.open("rb") as scp:
pickled_signal_candles = joblib.load(scp) pickled_signal_candles = joblib.load(scp)