From 96cea99d4f0a69bb9227546a2711dba21be274c2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 14 Jan 2025 19:57:39 +0100 Subject: [PATCH 01/19] refactor: move index-handling into generator --- freqtrade/optimize/backtesting.py | 43 +++++++++++++++++-------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 471769c2d..8ae7fc5dd 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1440,7 +1440,12 @@ class Backtesting: current_time += self.timeframe_td def time_pair_generator( - self, start_date: datetime, end_date: datetime, increment: timedelta, pairs: list[str] + self, + start_date: datetime, + end_date: datetime, + increment: timedelta, + pairs: list[str], + data: dict[str, list[tuple]], ): """ Backtest time and pair generator @@ -1451,9 +1456,11 @@ class Backtesting: self.progress.init_step( BacktestState.BACKTEST, int((end_date - start_date) / self.timeframe_td) ) - for current_time in self.time_generator(start_date, end_date): - # Loop for each time point. + # Indexes per pair, so some pairs are allowed to have a missing start. + indexes: dict = defaultdict(int) + for current_time in self.time_generator(start_date, end_date): + # Loop for each main candle. self.check_abort() # Reset open trade count for this candle # Critical to avoid exceeding max_open_trades in backtesting @@ -1467,7 +1474,18 @@ class Backtesting: new_pairlist = list(dict.fromkeys([t.pair for t in LocalTrade.bt_trades_open] + pairs)) for pair in new_pairlist: - yield current_time, pair + row_index = indexes[pair] + row = self.validate_row(data, pair, row_index, current_time) + if not row: + continue + + row_index += 1 + indexes[pair] = row_index + is_last_row = current_time == end_date + self.dataprovider._set_dataframe_max_index(self.required_startup + row_index) + self.dataprovider._set_dataframe_max_date(current_time) + + yield current_time, pair, row, is_last_row self.progress.increment() @@ -1492,23 +1510,10 @@ class Backtesting: # (looping lists is a lot faster than pandas DataFrames) data: dict = self._get_ohlcv_as_lists(processed) - # Indexes per pair, so some pairs are allowed to have a missing start. - indexes: dict = defaultdict(int) - # Loop timerange and get candle for each pair at that point in time - for current_time, pair in self.time_pair_generator( - start_date, end_date, self.timeframe_td, list(data.keys()) + for current_time, pair, row, is_last_row in self.time_pair_generator( + start_date, end_date, self.timeframe_td, list(data.keys()), data ): - row_index = indexes[pair] - row = self.validate_row(data, pair, row_index, current_time) - if not row: - continue - - row_index += 1 - indexes[pair] = row_index - is_last_row = current_time == end_date - self.dataprovider._set_dataframe_max_index(self.required_startup + row_index) - self.dataprovider._set_dataframe_max_date(current_time) trade_dir: LongShort | None = self.check_for_trade_entry(row) pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0 From 807fcffdae29fde28e10ecad44117b0135f3fb14 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 14 Jan 2025 20:15:17 +0100 Subject: [PATCH 02/19] chore: move more logic to generator --- freqtrade/optimize/backtesting.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 8ae7fc5dd..5444f99e0 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1485,7 +1485,11 @@ class Backtesting: self.dataprovider._set_dataframe_max_index(self.required_startup + row_index) self.dataprovider._set_dataframe_max_date(current_time) - yield current_time, pair, row, is_last_row + trade_dir: LongShort | None = self.check_for_trade_entry(row) + + pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0 + + yield current_time, pair, row, is_last_row, trade_dir, pair_has_open_trades self.progress.increment() @@ -1511,12 +1515,16 @@ class Backtesting: data: dict = self._get_ohlcv_as_lists(processed) # Loop timerange and get candle for each pair at that point in time - for current_time, pair, row, is_last_row in self.time_pair_generator( + for ( + current_time, + pair, + row, + is_last_row, + trade_dir, + pair_has_open_trades, + ) in self.time_pair_generator( start_date, end_date, self.timeframe_td, list(data.keys()), data ): - trade_dir: LongShort | None = self.check_for_trade_entry(row) - - pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0 if ( (trade_dir is not None or pair_has_open_trades) and self.timeframe_detail From f5be8fc70a87d9bdd1b6c26d047f7868f19ccb7e Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 14 Jan 2025 21:10:11 +0100 Subject: [PATCH 03/19] fix: switch backtest loop to have linear timing By running with timeframe-detail first, then pair, we can have linear timing This will avoid odd bugs due to in-candle closures closes #11217 --- freqtrade/optimize/backtesting.py | 125 +++++++++++++++++------------- 1 file changed, 73 insertions(+), 52 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 5444f99e0..4688fd060 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1412,7 +1412,7 @@ class Backtesting: return exiting_dir return None - def get_detail_data(self, pair: str, row: tuple) -> DataFrame | None: + def get_detail_data(self, pair: str, row: tuple) -> list[tuple] | None: """ Spread into detail data """ @@ -1431,7 +1431,7 @@ class Backtesting: 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] - return detail_data + return detail_data[HEADERS].values.tolist() def time_generator(self, start_date: datetime, end_date: datetime): current_time = start_date + self.timeframe_td @@ -1439,6 +1439,18 @@ class Backtesting: yield current_time current_time += self.timeframe_td + def time_generator_det(self, start_date: datetime, end_date: datetime): + if not self.timeframe_detail_td: + yield start_date, True, False, 0 + return + + current_time = start_date + i = 0 + while current_time <= end_date: + yield current_time, i == 0, True, i + i += 1 + current_time += self.timeframe_detail_td + def time_pair_generator( self, start_date: datetime, @@ -1469,28 +1481,71 @@ class Backtesting: strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)( current_time=current_time ) + pair_detail_cache = {} + pair_tradedir_cache: dict[LongShort | None] = {} + for current_time_det, is_first, has_detail, idx in self.time_generator_det( + current_time, current_time + increment + ): + # Loop for each detail candle. + # Yields only the start date if no detail timeframe is set. - # Pairs that have open trades should be processed first - new_pairlist = list(dict.fromkeys([t.pair for t in LocalTrade.bt_trades_open] + pairs)) + # Pairs that have open trades should be processed first + new_pairlist = list( + dict.fromkeys([t.pair for t in LocalTrade.bt_trades_open] + pairs) + ) + for pair in new_pairlist: + trade_dir: LongShort | None = None + if is_first: + # Main candle + row_index = indexes[pair] + row = self.validate_row(data, pair, row_index, current_time) + if not row: + continue - for pair in new_pairlist: - row_index = indexes[pair] - row = self.validate_row(data, pair, row_index, current_time) - if not row: - continue + row_index += 1 + indexes[pair] = row_index + is_last_row = current_time == end_date + self.dataprovider._set_dataframe_max_index( + self.required_startup + row_index + ) + trade_dir = self.check_for_trade_entry(row) + pair_tradedir_cache[pair] = trade_dir - row_index += 1 - indexes[pair] = row_index - is_last_row = current_time == end_date - self.dataprovider._set_dataframe_max_index(self.required_startup + row_index) - self.dataprovider._set_dataframe_max_date(current_time) + else: + # Detail candle - from cache. + detail_data = pair_detail_cache.get(pair) + if detail_data is None or len(detail_data) <= idx: + # logger.info(f"skipping {pair}, {current_time_det}, {trade_dir}") + continue + row = detail_data[idx] + trade_dir = pair_tradedir_cache.get(pair) - trade_dir: LongShort | None = self.check_for_trade_entry(row) + self.dataprovider._set_dataframe_max_date(current_time_det) - pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0 + pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0 - yield current_time, pair, row, is_last_row, trade_dir, pair_has_open_trades + if ( + is_first + and (trade_dir is not None or pair_has_open_trades) + and has_detail + and pair not in pair_detail_cache + and pair in self.detail_data + ): + # Spread candle into detail timeframe and cache that - + # only once per main candle + # and only if we can expect activity. + pair_detail_cache[pair] = self.get_detail_data(pair, row) + row = pair_detail_cache[pair][idx] + is_last_row = current_time_det == end_date + + yield ( + current_time_det, + pair, + row, + is_last_row, + trade_dir, + ) self.progress.increment() def backtest(self, processed: dict, start_date: datetime, end_date: datetime) -> dict[str, Any]: @@ -1521,44 +1576,10 @@ class Backtesting: row, is_last_row, trade_dir, - pair_has_open_trades, ) in self.time_pair_generator( start_date, end_date, self.timeframe_td, list(data.keys()), data ): - if ( - (trade_dir is not None or pair_has_open_trades) - 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 - # or when we got the signal for a new trade. - detail_data = self.get_detail_data(pair, row) - - if detail_data is None or len(detail_data) == 0: - # Fall back to "regular" data if no detail data was found for this candle - self.dataprovider._set_dataframe_max_date(current_time) - self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row) - continue - 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) - self.backtest_loop( - det_row, - pair, - current_time_det, - trade_dir, - is_first and not is_last_row, - ) - current_time_det += self.timeframe_detail_td - is_first = False - if pair_has_open_trades and not len(LocalTrade.bt_trades_open_pp[pair]) > 0: - # Auto-lock pair for the rest of the candle if the trade has been closed. - break - else: - self.dataprovider._set_dataframe_max_date(current_time) - self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row) + self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row) self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data) self.wallets.update() From 1e1b4239e750abd1b5a940539b568fc0364c3179 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 14 Jan 2025 21:29:43 +0100 Subject: [PATCH 04/19] chore: improve a few typings --- freqtrade/optimize/backtesting.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 4688fd060..a4f55c84c 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1481,8 +1481,8 @@ class Backtesting: strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)( current_time=current_time ) - pair_detail_cache = {} - pair_tradedir_cache: dict[LongShort | None] = {} + pair_detail_cache: dict[str, list[tuple]] = {} + pair_tradedir_cache: dict[str, LongShort | None] = {} for current_time_det, is_first, has_detail, idx in self.time_generator_det( current_time, current_time + increment ): @@ -1530,11 +1530,14 @@ class Backtesting: and has_detail and pair not in pair_detail_cache and pair in self.detail_data + and row ): # Spread candle into detail timeframe and cache that - # only once per main candle # and only if we can expect activity. - pair_detail_cache[pair] = self.get_detail_data(pair, row) + pair_detail = self.get_detail_data(pair, row) + if pair_detail is not None: + pair_detail_cache[pair] = pair_detail row = pair_detail_cache[pair][idx] is_last_row = current_time_det == end_date From cbe38c6f41cfb64158ff76e6d5008c7df580b8c6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 15 Jan 2025 06:46:33 +0100 Subject: [PATCH 05/19] fix: don't detail-loop pairs if a trade closed within the current candle. --- freqtrade/optimize/backtesting.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index a4f55c84c..b46c00bb6 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1483,6 +1483,7 @@ class Backtesting: ) pair_detail_cache: dict[str, list[tuple]] = {} pair_tradedir_cache: dict[str, LongShort | None] = {} + pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open] for current_time_det, is_first, has_detail, idx in self.time_generator_det( current_time, current_time + increment ): @@ -1523,6 +1524,10 @@ class Backtesting: self.dataprovider._set_dataframe_max_date(current_time_det) pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0 + if pair in pairs_with_open_trades and not pair_has_open_trades: + # Pair has had open trades which closed in the current main candle. + # Skip this pair for this timeframe + continue if ( is_first From a326af830fc710da14db470488e8e800534e8aea Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 15 Jan 2025 07:05:52 +0100 Subject: [PATCH 06/19] chore: simplify backtest loop interface --- freqtrade/optimize/backtesting.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index b46c00bb6..716e7c78e 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1455,7 +1455,6 @@ class Backtesting: self, start_date: datetime, end_date: datetime, - increment: timedelta, pairs: list[str], data: dict[str, list[tuple]], ): @@ -1464,7 +1463,7 @@ class Backtesting: :returns: generator of (current_time, pair, is_first) where is_first is True for the first pair of each new candle """ - current_time = start_date + increment + current_time = start_date + self.timeframe_td self.progress.init_step( BacktestState.BACKTEST, int((end_date - start_date) / self.timeframe_td) ) @@ -1484,8 +1483,9 @@ class Backtesting: pair_detail_cache: dict[str, list[tuple]] = {} pair_tradedir_cache: dict[str, LongShort | None] = {} pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open] + for current_time_det, is_first, has_detail, idx in self.time_generator_det( - current_time, current_time + increment + current_time, current_time + self.timeframe_td ): # Loop for each detail candle. # Yields only the start date if no detail timeframe is set. @@ -1584,9 +1584,7 @@ class Backtesting: row, is_last_row, trade_dir, - ) in self.time_pair_generator( - start_date, end_date, self.timeframe_td, list(data.keys()), data - ): + ) in self.time_pair_generator(start_date, end_date, list(data.keys()), data): self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row) self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data) From 2b4d3b3f1591bf6a45b5568c03e06e9a026a075d Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 15 Jan 2025 07:10:37 +0100 Subject: [PATCH 07/19] refactor: extract detail/pair loop to separate generator --- freqtrade/optimize/backtesting.py | 124 +++++++++++++++--------------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 716e7c78e..a9b75a93d 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1451,6 +1451,18 @@ class Backtesting: i += 1 current_time += self.timeframe_detail_td + def time_pair_generator_det(self, current_time: datetime, pairs: list[str]): + for current_time_det, is_first, has_detail, idx in self.time_generator_det( + current_time, current_time + self.timeframe_td + ): + # Loop for each detail candle. + # Yields only the start date if no detail timeframe is set. + + # Pairs that have open trades should be processed first + new_pairlist = list(dict.fromkeys([t.pair for t in LocalTrade.bt_trades_open] + pairs)) + for pair in new_pairlist: + yield current_time_det, is_first, has_detail, idx, pair + def time_pair_generator( self, start_date: datetime, @@ -1484,76 +1496,64 @@ class Backtesting: pair_tradedir_cache: dict[str, LongShort | None] = {} pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open] - for current_time_det, is_first, has_detail, idx in self.time_generator_det( - current_time, current_time + self.timeframe_td + for current_time_det, is_first, has_detail, idx, pair in self.time_pair_generator_det( + current_time, pairs ): - # Loop for each detail candle. + # Loop for each detail candle (if necessary) and pair # Yields only the start date if no detail timeframe is set. # Pairs that have open trades should be processed first - new_pairlist = list( - dict.fromkeys([t.pair for t in LocalTrade.bt_trades_open] + pairs) - ) - for pair in new_pairlist: - trade_dir: LongShort | None = None - if is_first: - # Main candle - row_index = indexes[pair] - row = self.validate_row(data, pair, row_index, current_time) - if not row: - continue - - row_index += 1 - indexes[pair] = row_index - is_last_row = current_time == end_date - self.dataprovider._set_dataframe_max_index( - self.required_startup + row_index - ) - trade_dir = self.check_for_trade_entry(row) - pair_tradedir_cache[pair] = trade_dir - - else: - # Detail candle - from cache. - detail_data = pair_detail_cache.get(pair) - if detail_data is None or len(detail_data) <= idx: - # logger.info(f"skipping {pair}, {current_time_det}, {trade_dir}") - continue - row = detail_data[idx] - trade_dir = pair_tradedir_cache.get(pair) - - self.dataprovider._set_dataframe_max_date(current_time_det) - - pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0 - if pair in pairs_with_open_trades and not pair_has_open_trades: - # Pair has had open trades which closed in the current main candle. - # Skip this pair for this timeframe + trade_dir: LongShort | None = None + if is_first: + # Main candle + row_index = indexes[pair] + row = self.validate_row(data, pair, row_index, current_time) + if not row: continue - if ( - is_first - and (trade_dir is not None or pair_has_open_trades) - and has_detail - and pair not in pair_detail_cache - and pair in self.detail_data - and row - ): - # Spread candle into detail timeframe and cache that - - # only once per main candle - # and only if we can expect activity. - pair_detail = self.get_detail_data(pair, row) - if pair_detail is not None: - pair_detail_cache[pair] = pair_detail - row = pair_detail_cache[pair][idx] + row_index += 1 + indexes[pair] = row_index + is_last_row = current_time == end_date + self.dataprovider._set_dataframe_max_index(self.required_startup + row_index) + trade_dir = self.check_for_trade_entry(row) + pair_tradedir_cache[pair] = trade_dir - is_last_row = current_time_det == end_date + else: + # Detail candle - from cache. + detail_data = pair_detail_cache.get(pair) + if detail_data is None or len(detail_data) <= idx: + # logger.info(f"skipping {pair}, {current_time_det}, {trade_dir}") + continue + row = detail_data[idx] + trade_dir = pair_tradedir_cache.get(pair) - yield ( - current_time_det, - pair, - row, - is_last_row, - trade_dir, - ) + self.dataprovider._set_dataframe_max_date(current_time_det) + + pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0 + if pair in pairs_with_open_trades and not pair_has_open_trades: + # Pair has had open trades which closed in the current main candle. + # Skip this pair for this timeframe + continue + + if ( + is_first + and (trade_dir is not None or pair_has_open_trades) + and has_detail + and pair not in pair_detail_cache + and pair in self.detail_data + and row + ): + # Spread candle into detail timeframe and cache that - + # only once per main candle + # and only if we can expect activity. + pair_detail = self.get_detail_data(pair, row) + if pair_detail is not None: + pair_detail_cache[pair] = pair_detail + row = pair_detail_cache[pair][idx] + + is_last_row = current_time_det == end_date + + yield current_time_det, pair, row, is_last_row, trade_dir self.progress.increment() def backtest(self, processed: dict, start_date: datetime, end_date: datetime) -> dict[str, Any]: From a6601ba7d210f7306d7a79df5f8023f0de14b62b Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 15 Jan 2025 07:11:18 +0100 Subject: [PATCH 08/19] refactor: mark time_generators private --- freqtrade/optimize/backtesting.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index a9b75a93d..91b2bb4fd 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1433,13 +1433,13 @@ class Backtesting: detail_data.loc[:, "exit_tag"] = row[EXIT_TAG_IDX] return detail_data[HEADERS].values.tolist() - def time_generator(self, start_date: datetime, end_date: datetime): + def _time_generator(self, start_date: datetime, end_date: datetime): current_time = start_date + self.timeframe_td while current_time <= end_date: yield current_time current_time += self.timeframe_td - def time_generator_det(self, start_date: datetime, end_date: datetime): + def _time_generator_det(self, start_date: datetime, end_date: datetime): if not self.timeframe_detail_td: yield start_date, True, False, 0 return @@ -1451,8 +1451,8 @@ class Backtesting: i += 1 current_time += self.timeframe_detail_td - def time_pair_generator_det(self, current_time: datetime, pairs: list[str]): - for current_time_det, is_first, has_detail, idx in self.time_generator_det( + def _time_pair_generator_det(self, current_time: datetime, pairs: list[str]): + for current_time_det, is_first, has_detail, idx in self._time_generator_det( current_time, current_time + self.timeframe_td ): # Loop for each detail candle. @@ -1482,7 +1482,7 @@ class Backtesting: # Indexes per pair, so some pairs are allowed to have a missing start. indexes: dict = defaultdict(int) - for current_time in self.time_generator(start_date, end_date): + for current_time in self._time_generator(start_date, end_date): # Loop for each main candle. self.check_abort() # Reset open trade count for this candle @@ -1496,11 +1496,11 @@ class Backtesting: pair_tradedir_cache: dict[str, LongShort | None] = {} pairs_with_open_trades = [t.pair for t in LocalTrade.bt_trades_open] - for current_time_det, is_first, has_detail, idx, pair in self.time_pair_generator_det( + for current_time_det, is_first, has_detail, idx, pair in self._time_pair_generator_det( current_time, pairs ): # Loop for each detail candle (if necessary) and pair - # Yields only the start date if no detail timeframe is set. + # Yields only the main date if no detail timeframe is set. # Pairs that have open trades should be processed first trade_dir: LongShort | None = None From e81489807d418130ed287099b2a74f81d45c0323 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 15 Jan 2025 18:11:46 +0100 Subject: [PATCH 09/19] fix: prevent multiple intra-candle trades --- freqtrade/optimize/backtesting.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 91b2bb4fd..5ecb0d800 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1534,6 +1534,11 @@ class Backtesting: # Pair has had open trades which closed in the current main candle. # Skip this pair for this timeframe continue + if pair_has_open_trades and pair not in pairs_with_open_trades: + # auto-lock for pairs that have open trades + # Necessary for detail - to capture trades that open and close within + # the same main candle + pairs_with_open_trades.append(pair) if ( is_first From 4a6027b43f6b0f011d5dbd9ce2bc3c2e12d1a8c0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Wed, 15 Jan 2025 18:15:15 +0100 Subject: [PATCH 10/19] test: update pair_detail_simplified test parallel analysis should cover more detailed cases to prevent intra-candle parallelism --- tests/optimize/test_backtesting.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 81d63cf64..b34e533e4 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -1772,15 +1772,18 @@ def test_backtest_multi_pair_detail_simplified( if use_detail: # Backtest loop is called once per candle per pair # Exact numbers depend on trade state - but should be around 3_800 - assert bl_spy.call_count > 3_350 - assert bl_spy.call_count < 3_800 + assert bl_spy.call_count > 2_250 + assert bl_spy.call_count < 2_800 else: assert bl_spy.call_count < 995 # Make sure we have parallel trades assert len(evaluate_result_multi(results["results"], "1h", 2)) > 0 # make sure we don't have trades with more than configured max_open_trades + # This must evaluate on detail timeframe - as we can have entries within the candle. assert len(evaluate_result_multi(results["results"], "1h", 3)) == 0 + assert len(evaluate_result_multi(results["results"], "5m", 3)) == 0 + assert len(evaluate_result_multi(results["results"], "1m", 3)) == 0 # # Cached data correctly removed amounts offset = 1 if tres == 0 else 0 @@ -1800,6 +1803,8 @@ def test_backtest_multi_pair_detail_simplified( } results = backtesting.backtest(**backtest_conf) assert len(evaluate_result_multi(results["results"], "1h", 1)) == 0 + assert len(evaluate_result_multi(results["results"], "5m", 1)) == 0 + assert len(evaluate_result_multi(results["results"], "1m", 1)) == 0 @pytest.mark.parametrize("use_detail", [True, False]) From 5f89708be45b060e9844c5c2ad4f39694ebfaf43 Mon Sep 17 00:00:00 2001 From: Matthias Date: Thu, 16 Jan 2025 19:47:12 +0100 Subject: [PATCH 11/19] test: fix long_short switch test --- tests/optimize/test_backtesting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index b34e533e4..2f34b8ca8 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -1901,7 +1901,7 @@ def test_backtest_multi_pair_long_short_switch( if use_detail: # Backtest loop is called once per candle per pair - assert bl_spy.call_count == 1484 + assert bl_spy.call_count == 1482 else: assert bl_spy.call_count == 479 From d02b4d4c3f0f3bf9820e2f4d0c888fa146a9c5ef Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 17 Jan 2025 06:36:05 +0100 Subject: [PATCH 12/19] test: Fix detail futures test --- tests/optimize/test_backtesting.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 2f34b8ca8..47f196813 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -936,7 +936,7 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail) @pytest.mark.parametrize( "use_detail,exp_funding_fee, exp_ff_updates", [ - (True, -0.018054162, 11), + (True, -0.018054162, 10), (False, -0.01780296, 6), ], ) @@ -998,7 +998,7 @@ def test_backtest_one_detail_futures( results = result["results"] assert not results.empty # Timeout settings from default_conf = entry: 10, exit: 30 - assert len(results) == (5 if use_detail else 2) + assert len(results) == (4 if use_detail else 2) assert "orders" in results.columns data_pair = processed[pair] From 419d5d99464cb4010461b649a33d6f97e56b743f Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 17 Jan 2025 06:36:32 +0100 Subject: [PATCH 13/19] feat: add backtesting support for ignore_buying_expired_candle_after this is only relevant for detail candles - otherwise entries will never happen within a candle. --- freqtrade/optimize/backtesting.py | 9 +++++++++ tests/optimize/test_backtesting.py | 1 + 2 files changed, 10 insertions(+) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 5ecb0d800..96c962869 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1527,6 +1527,15 @@ class Backtesting: row = detail_data[idx] trade_dir = pair_tradedir_cache.get(pair) + if self.strategy.ignore_expired_candle( + current_time - self.timeframe_td, # last closed candle is 1 timeframe away. + current_time_det, + self.timeframe_secs, + trade_dir is not None, + ): + # Ignore late entries eventually + trade_dir = None + self.dataprovider._set_dataframe_max_date(current_time_det) pair_has_open_trades = len(LocalTrade.bt_trades_open_pp[pair]) > 0 diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 47f196813..9e45eedec 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -866,6 +866,7 @@ def test_backtest_one_detail(default_conf_usdt, mocker, testdatadir, use_detail) backtesting = Backtesting(default_conf_usdt) backtesting._set_strategy(backtesting.strategylist[0]) backtesting.strategy.populate_entry_trend = advise_entry + backtesting.strategy.ignore_buying_expired_candle_after = 59 backtesting.strategy.custom_entry_price = custom_entry_price pair = "XRP/ETH" # Pick a timerange adapted to the pair we use to test From 1e61aea23b31e7dfca72a037bed71b6bb3098262 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 17 Jan 2025 06:45:56 +0100 Subject: [PATCH 14/19] feat: allow in-candle entries --- freqtrade/optimize/backtesting.py | 2 +- tests/optimize/test_backtesting.py | 11 ++++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 96c962869..e9b93d7d7 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1386,7 +1386,7 @@ class Backtesting: and (self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0) and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir) ): - if self.trade_slot_available(LocalTrade.bt_open_open_trade_count_candle): + if self.trade_slot_available(LocalTrade.bt_open_open_trade_count): trade = self._enter_trade(pair, row, trade_dir) if trade: self.wallets.update() diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 9e45eedec..b85e2e2d5 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -1773,16 +1773,18 @@ def test_backtest_multi_pair_detail_simplified( if use_detail: # Backtest loop is called once per candle per pair # Exact numbers depend on trade state - but should be around 3_800 - assert bl_spy.call_count > 2_250 + assert bl_spy.call_count > 2_170 assert bl_spy.call_count < 2_800 + assert len(evaluate_result_multi(results["results"], "1h", 3)) > 0 else: assert bl_spy.call_count < 995 + assert len(evaluate_result_multi(results["results"], "1h", 3)) == 0 # Make sure we have parallel trades assert len(evaluate_result_multi(results["results"], "1h", 2)) > 0 + assert len(evaluate_result_multi(results["results"], "5m", 2)) > 0 # make sure we don't have trades with more than configured max_open_trades # This must evaluate on detail timeframe - as we can have entries within the candle. - assert len(evaluate_result_multi(results["results"], "1h", 3)) == 0 assert len(evaluate_result_multi(results["results"], "5m", 3)) == 0 assert len(evaluate_result_multi(results["results"], "1m", 3)) == 0 @@ -1803,7 +1805,10 @@ def test_backtest_multi_pair_detail_simplified( "end_date": max_date, } results = backtesting.backtest(**backtest_conf) - assert len(evaluate_result_multi(results["results"], "1h", 1)) == 0 + if use_detail: + assert len(evaluate_result_multi(results["results"], "1h", 1)) > 0 + else: + assert len(evaluate_result_multi(results["results"], "1h", 1)) == 0 assert len(evaluate_result_multi(results["results"], "5m", 1)) == 0 assert len(evaluate_result_multi(results["results"], "1m", 1)) == 0 From 733cd22deec49d7c3654255f2d4f723b39750223 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 17 Jan 2025 06:48:01 +0100 Subject: [PATCH 15/19] chore: remove bt_open_open_trade_count_candle it's no longer necessary if we support in-candle entries --- freqtrade/optimize/backtesting.py | 1 - freqtrade/persistence/trade_model.py | 11 ----------- tests/persistence/test_persistence.py | 1 - 3 files changed, 13 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index e9b93d7d7..e52ec3850 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1488,7 +1488,6 @@ class Backtesting: # Reset open trade count for this candle # Critical to avoid exceeding max_open_trades in backtesting # when timeframe-detail is used and trades close within the opening candle. - LocalTrade.bt_open_open_trade_count_candle = LocalTrade.bt_open_open_trade_count strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)( current_time=current_time ) diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 6ecd44b4b..b6ac40977 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -391,7 +391,6 @@ class LocalTrade: # Copy of trades_open - but indexed by pair bt_trades_open_pp: dict[str, list["LocalTrade"]] = defaultdict(list) bt_open_open_trade_count: int = 0 - bt_open_open_trade_count_candle: int = 0 bt_total_profit: float = 0 realized_profit: float = 0 @@ -760,7 +759,6 @@ class LocalTrade: LocalTrade.bt_trades_open = [] LocalTrade.bt_trades_open_pp = defaultdict(list) LocalTrade.bt_open_open_trade_count = 0 - LocalTrade.bt_open_open_trade_count_candle = 0 LocalTrade.bt_total_profit = 0 def adjust_min_max_rates(self, current_price: float, current_price_low: float) -> None: @@ -1462,11 +1460,6 @@ class LocalTrade: LocalTrade.bt_trades_open.remove(trade) LocalTrade.bt_trades_open_pp[trade.pair].remove(trade) LocalTrade.bt_open_open_trade_count -= 1 - if (trade.close_date_utc - trade.open_date_utc) > timedelta(minutes=trade.timeframe): - # Only subtract trades that are open for more than 1 candle - # To avoid exceeding max_open_trades. - # Must be reset at the start of every candle during backesting. - LocalTrade.bt_open_open_trade_count_candle -= 1 LocalTrade.bt_trades.append(trade) LocalTrade.bt_total_profit += trade.close_profit_abs @@ -1476,7 +1469,6 @@ class LocalTrade: LocalTrade.bt_trades_open.append(trade) LocalTrade.bt_trades_open_pp[trade.pair].append(trade) LocalTrade.bt_open_open_trade_count += 1 - LocalTrade.bt_open_open_trade_count_candle += 1 else: LocalTrade.bt_trades.append(trade) @@ -1485,9 +1477,6 @@ class LocalTrade: LocalTrade.bt_trades_open.remove(trade) LocalTrade.bt_trades_open_pp[trade.pair].remove(trade) LocalTrade.bt_open_open_trade_count -= 1 - # TODO: The below may have odd behavior in case of canceled entries - # It might need to be removed so the trade "counts" as open for this candle. - LocalTrade.bt_open_open_trade_count_candle -= 1 @staticmethod def get_open_trades() -> list[Any]: diff --git a/tests/persistence/test_persistence.py b/tests/persistence/test_persistence.py index 9109fce43..b9606ee47 100644 --- a/tests/persistence/test_persistence.py +++ b/tests/persistence/test_persistence.py @@ -2145,7 +2145,6 @@ def test_Trade_object_idem(): "bt_trades_open", "bt_trades_open_pp", "bt_open_open_trade_count", - "bt_open_open_trade_count_candle", "bt_total_profit", "from_json", ) From 26983c637aaae67ed1a0541c321618a28c0de550 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 17 Jan 2025 07:07:25 +0100 Subject: [PATCH 16/19] docs: document timeframe-detail potential difference --- docs/backtesting.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/backtesting.md b/docs/backtesting.md index 3c82bdd94..133f288f2 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -508,7 +508,12 @@ To utilize this, you can append `--timeframe-detail 5m` to your regular backtest freqtrade backtesting --strategy AwesomeStrategy --timeframe 1h --timeframe-detail 5m ``` -This will load 1h data as well as 5m data for the timeframe. The strategy will be analyzed with the 1h timeframe, and Entry orders will only be placed at the main timeframe, however Order fills and exit signals will be evaluated at the 5m candle, simulating intra-candle movements. +This will load 1h data (the main timeframe) as well as 5m data (detail timeframe) for the selected timerange. +The strategy will be analyzed with the 1h timeframe. +Candles where activity may take place (there's an active signal, the pair is in a trade) are evaluated at the 5m timeframe. +This will allow for a more accurate simulation of intra-candle movements - and can lead to different results, especially on higher timeframes. + +Entries will generally still happen at the main candle's open, however freed trade slots may be freed earlier (if the exit signal is triggered on the 5m candle), which can then be used for a new trade of a different pair. All callback functions (`custom_exit()`, `custom_stoploss()`, ... ) will be running for each 5m candle once the trade is opened (so 12 times in the above example of 1h timeframe, and 5m detailed timeframe). @@ -520,6 +525,27 @@ Also, data must be available / downloaded already. !!! Tip You can use this function as the last part of strategy development, to ensure your strategy is not exploiting one of the [backtesting assumptions](#assumptions-made-by-backtesting). Strategies that perform similarly well with this mode have a good chance to perform well in dry/live modes too (although only forward-testing (dry-mode) can really confirm a strategy). +??? Sample "Extreme Difference Example" + Using `--timeframe-detail` on an extreme example (all below pairs have the 10:00 candle with an entry signal) may lead to the following backtesting Trade sequence with 1 max_open_trades: + + | Pair | Entry Time | Exit Time | Duration | + |------|------------|-----------| -------- | + | BTC/USDT | 2024-01-01 10:00:00 | 2021-01-01 10:05:00 | 5m | + | ETH/USDT | 2024-01-01 10:05:00 | 2021-01-01 10:15:00 | 10m | + | XRP/USDT | 2024-01-01 10:15:00 | 2021-01-01 10:30:00 | 15m | + | SOL/USDT | 2024-01-01 10:15:00 | 2021-01-01 11:05:00 | 50m | + | BTC/USDT | 2024-01-01 11:05:00 | 2021-01-01 12:00:00 | 55m | + + Without timeframe-detail, this would look like: + + | Pair | Entry Time | Exit Time | Duration | + |------|------------|-----------| -------- | + | BTC/USDT | 2024-01-01 10:00:00 | 2021-01-01 11:00:00 | 1h | + | BTC/USDT | 2024-01-01 11:00:00 | 2021-01-01 12:00:00 | 1h | + + The difference is significant, as without detail data, only the first `max_open_trades` signals per candle are evaluated, and the trade slots are only freed at the end of the candle, allowing for a new trade to be opened at the next candle. + + ## Backtesting multiple strategies To compare multiple strategies, a list of Strategies can be provided to backtesting. From f7f78ad2a4a262c04e18be61798c749ddcd4db9a Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 17 Jan 2025 19:01:11 +0100 Subject: [PATCH 17/19] test: fix test comment --- tests/optimize/test_backtesting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index b85e2e2d5..81aca35a6 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -1772,7 +1772,7 @@ def test_backtest_multi_pair_detail_simplified( if use_detail: # Backtest loop is called once per candle per pair - # Exact numbers depend on trade state - but should be around 3_800 + # Exact numbers depend on trade state - but should be around 2_600 assert bl_spy.call_count > 2_170 assert bl_spy.call_count < 2_800 assert len(evaluate_result_multi(results["results"], "1h", 3)) > 0 From e350dbd5529e3d4a4626deef78379bd48a21c4e8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 17 Jan 2025 19:18:28 +0100 Subject: [PATCH 18/19] refactor: simplify backtesting class --- freqtrade/optimize/backtesting.py | 35 ++++++++++-------------------- tests/optimize/test_backtesting.py | 4 ++-- 2 files changed, 14 insertions(+), 25 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index e52ec3850..5a809aaf6 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1335,28 +1335,6 @@ class Backtesting: current_time: datetime, trade_dir: LongShort | None, can_enter: bool, - ) -> None: - """ - Conditionally call backtest_loop_inner a 2nd time if shorting is enabled, - a position closed and a new signal in the other direction is available. - """ - if not self._can_short or trade_dir is None: - # No need to reverse position if shorting is disabled or there's no new signal - self.backtest_loop_inner(row, pair, current_time, trade_dir, can_enter) - else: - for _ in (0, 1): - a = self.backtest_loop_inner(row, pair, current_time, trade_dir, can_enter) - if not a or a == trade_dir: - # the trade didn't close or position change is in the same direction - break - - def backtest_loop_inner( - self, - row: tuple, - pair: str, - current_time: datetime, - trade_dir: LongShort | None, - can_enter: bool, ) -> LongShort | None: """ NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized. @@ -1598,7 +1576,18 @@ class Backtesting: is_last_row, trade_dir, ) in self.time_pair_generator(start_date, end_date, list(data.keys()), data): - self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row) + if not self._can_short or trade_dir is None: + # No need to reverse position if shorting is disabled or there's no new signal + self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row) + else: + # Conditionally call backtest_loop a 2nd time if shorting is enabled, + # a position closed and a new signal in the other direction is available. + + for _ in (0, 1): + a = self.backtest_loop(row, pair, current_time, trade_dir, not is_last_row) + if not a or a == trade_dir: + # the trade didn't close or position change is in the same direction + break self.handle_left_open(LocalTrade.bt_trades_open_pp, data=data) self.wallets.update() diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 81aca35a6..08d8f1107 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -1907,9 +1907,9 @@ def test_backtest_multi_pair_long_short_switch( if use_detail: # Backtest loop is called once per candle per pair - assert bl_spy.call_count == 1482 + assert bl_spy.call_count == 1511 else: - assert bl_spy.call_count == 479 + assert bl_spy.call_count == 508 # Make sure we have parallel trades assert len(evaluate_result_multi(results["results"], "5m", 0)) > 0 From 6b1af9b9a24f83f415b16253fa2ec024f8ae12c1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 17 Jan 2025 19:37:15 +0100 Subject: [PATCH 19/19] chore: move docstring to the right place --- freqtrade/optimize/backtesting.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 5a809aaf6..9d88dbb70 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -1418,6 +1418,10 @@ class Backtesting: current_time += self.timeframe_td def _time_generator_det(self, start_date: datetime, end_date: datetime): + """ + Loop for each detail candle. + Yields only the start date if no detail timeframe is set. + """ if not self.timeframe_detail_td: yield start_date, True, False, 0 return @@ -1433,9 +1437,6 @@ class Backtesting: for current_time_det, is_first, has_detail, idx in self._time_generator_det( current_time, current_time + self.timeframe_td ): - # Loop for each detail candle. - # Yields only the start date if no detail timeframe is set. - # Pairs that have open trades should be processed first new_pairlist = list(dict.fromkeys([t.pair for t in LocalTrade.bt_trades_open] + pairs)) for pair in new_pairlist: @@ -1450,8 +1451,8 @@ class Backtesting: ): """ Backtest time and pair generator - :returns: generator of (current_time, pair, is_first) - where is_first is True for the first pair of each new candle + :returns: generator of (current_time, pair, row, is_last_row, trade_dir) + where is_last_row is a boolean indicating if this is the data end date. """ current_time = start_date + self.timeframe_td self.progress.init_step(