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
DataFrame of the candles that resulted in buy signals. Depending on how many buys your strategy
makes, this file may get quite large, so periodically check your `user_data/backtest_results`
folder to delete old exports.
DataFrame of the candles that resulted in entry and exit signals.
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.
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.
If all goes well, you should now see a `backtest-result-{timestamp}_signals.pkl` file in the
`user_data/backtest_results` folder.
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.
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:
@ -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
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
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_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

View File

@ -1,6 +1,6 @@
import logging
from pathlib import Path
from typing import List
from typing import Dict, List
import joblib
import pandas as pd
@ -8,6 +8,7 @@ import pandas as pd
from freqtrade.configuration import TimeRange
from freqtrade.constants import Config
from freqtrade.data.btanalysis import (
BT_DATA_COLUMNS,
get_latest_backtest_filename,
load_backtest_data,
load_backtest_stats,
@ -47,9 +48,14 @@ def _load_signal_candles(backtest_dir: Path):
return _load_backtest_analysis_data(backtest_dir, "signals")
def _process_candles_and_indicators(pairlist, strategy_name, trades, signal_candles):
analysed_trades_dict = {}
analysed_trades_dict[strategy_name] = {}
def _load_exit_signal_candles(backtest_dir: Path) -> 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

View File

@ -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.

View File

@ -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")

View File

@ -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()]

View File

@ -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"])

View File

@ -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-<timestamp>_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)