From c12e5a3b6c9c4d09fbc988f7f0ec5a107890a2f8 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Jan 2022 19:39:42 +0100 Subject: [PATCH 01/26] Initial idea backtesting order timeout --- freqtrade/optimize/backtesting.py | 40 ++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 3dd8986d3..4b9d7bbf1 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -380,6 +380,10 @@ class Backtesting: return trade + def _get_order_filled(self, rate: float, row: Tuple) -> bool: + """ Rate is within candle, therefore filled""" + return row[LOW_IDX] < rate < row[HIGH_IDX] + def _get_sell_trade_entry_for_candle(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]: @@ -405,6 +409,7 @@ class Backtesting: closerate = self._get_close_rate(sell_row, trade, sell, trade_dur) # call the custom exit price,with default value as previous closerate current_profit = trade.calc_profit_ratio(closerate) + order_closed = True if sell.sell_type in (SellType.SELL_SIGNAL, SellType.CUSTOM_SELL): # Custom exit pricing only for sell-signals closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, @@ -412,8 +417,7 @@ class Backtesting: pair=trade.pair, trade=trade, current_time=sell_row[DATE_IDX], proposed_rate=closerate, current_profit=current_profit) - # Use the maximum between close_rate and low as we cannot sell outside of a candle. - closerate = min(max(closerate, sell_row[LOW_IDX]), sell_row[HIGH_IDX]) + order_closed = self._get_order_filled(closerate, sell_row) # Confirm trade exit: time_in_force = self.strategy.order_time_in_force['sell'] @@ -437,6 +441,21 @@ class Backtesting: trade.sell_reason = sell_row[EXIT_TAG_IDX] trade.close(closerate, show_msg=False) + order = Order( + ft_is_open=order_closed, + ft_pair=trade.pair, + symbol=trade.pair, + ft_order_side="buy", + side="buy", + order_type="market", + status="closed", + price=closerate, + average=closerate, + amount=trade.amount, + filled=trade.amount, + cost=trade.amount * closerate + ) + trade.orders.append(order) return trade return None @@ -480,9 +499,6 @@ class Backtesting: pair=pair, current_time=current_time, proposed_rate=row[OPEN_IDX], entry_tag=entry_tag) # default value is the open rate - # Move rate to within the candle's low/high rate - propose_rate = min(max(propose_rate, row[LOW_IDX]), row[HIGH_IDX]) - min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0 max_stake_amount = self.wallets.get_available_stake_amount() @@ -534,9 +550,10 @@ class Backtesting: orders=[] ) trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) + order_filled = self._get_order_filled(propose_rate, row) order = Order( - ft_is_open=False, + ft_is_open=order_filled, ft_pair=trade.pair, symbol=trade.pair, ft_order_side="buy", @@ -552,6 +569,8 @@ class Backtesting: filled=amount, cost=stake_amount + trade.fee_open ) + if not order_filled: + trade.open_order_id = 'buy' trade.orders.append(order) if pos_adjust: trade.recalc_trade_from_orders() @@ -647,6 +666,15 @@ class Backtesting: indexes[pair] = row_index self.dataprovider._set_dataframe_max_index(row_index) + # Check order filling + for open_trade in list(open_trades[pair]): + # TODO: should open orders be stored in a separate list? + if open_trade.open_order_id: + # FIXME: check order filling + # * Get open order + # * check if filled + open_trade.open_order_id = None + # without positionstacking, we can only have one open trade per pair. # max_open_trades must be respected # don't open on the last row From f7a1cabe23018945d561b482fbe32fb836004347 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 16 Jan 2022 19:52:08 +0100 Subject: [PATCH 02/26] Add first version to fill orders "later" in backtesting --- freqtrade/optimize/backtesting.py | 12 ++++++++---- freqtrade/persistence/models.py | 7 +++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 4b9d7bbf1..0e9d95f53 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -670,10 +670,11 @@ class Backtesting: for open_trade in list(open_trades[pair]): # TODO: should open orders be stored in a separate list? if open_trade.open_order_id: - # FIXME: check order filling - # * Get open order - # * check if filled - open_trade.open_order_id = None + order = open_trade.select_order(is_open=True) + # Check for timeout!! + if self._get_order_filled(order.price): + open_trade.open_order_id = None + order.ft_is_open = False # without positionstacking, we can only have one open trade per pair. # max_open_trades must be respected @@ -698,6 +699,9 @@ class Backtesting: LocalTrade.add_bt_trade(trade) for trade in list(open_trades[pair]): + # TODO: This could be avoided with a separate list + if trade.open_order_id: + continue # also check the buying candle for sell conditions. trade_entry = self._get_sell_trade_entry(trade, row) # Sell occurred diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index ff500b549..ee7ad3fdd 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -635,14 +635,17 @@ class LocalTrade(): if self.stop_loss_pct is not None and self.open_rate is not None: self.adjust_stop_loss(self.open_rate, self.stop_loss_pct) - def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]: + def select_order( + self, order_side: str = None, is_open: Optional[bool] = None) -> Optional[Order]: """ Finds latest order for this orderside and status :param order_side: Side of the order (either 'buy' or 'sell') :param is_open: Only search for open orders? :return: latest Order object if it exists, else None """ - orders = [o for o in self.orders if o.side == order_side] + orders = self.orders + if order_side: + orders = [o for o in self.orders if o.side == order_side] if is_open is not None: orders = [o for o in orders if o.ft_is_open == is_open] if len(orders) > 0: From 15698dd1ca5d92f678aa1eefc3fc1841d95ca9a2 Mon Sep 17 00:00:00 2001 From: Rokas Kupstys Date: Wed, 19 Jan 2022 11:42:24 +0200 Subject: [PATCH 03/26] Fix errors so it runs, implement timeout handling. --- freqtrade/optimize/backtesting.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 0e9d95f53..d26fe1411 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -539,6 +539,7 @@ class Backtesting: trade = LocalTrade( pair=pair, open_rate=propose_rate, + open_rate_requested=propose_rate, open_date=current_time, stake_amount=stake_amount, amount=amount, @@ -553,7 +554,8 @@ class Backtesting: order_filled = self._get_order_filled(propose_rate, row) order = Order( - ft_is_open=order_filled, + order_date=current_time, + ft_is_open=not order_filled, ft_pair=trade.pair, symbol=trade.pair, ft_order_side="buy", @@ -566,7 +568,8 @@ class Backtesting: price=propose_rate, average=propose_rate, amount=amount, - filled=amount, + filled=amount if order_filled else 0, + remaining=0 if order_filled else amount, cost=stake_amount + trade.fee_open ) if not order_filled: @@ -671,10 +674,28 @@ class Backtesting: # TODO: should open orders be stored in a separate list? if open_trade.open_order_id: order = open_trade.select_order(is_open=True) - # Check for timeout!! - if self._get_order_filled(order.price): + if order is None: + continue + if self._get_order_filled(order.price, row): open_trade.open_order_id = None order.ft_is_open = False + order.filled = order.price + order.remaining = 0 + timeout = self.config['unfilledtimeout'].get(order.side, 0) + if 0 < timeout <= (tmp - order.order_date).seconds / 60: + open_trade.open_order_id = None + order.ft_is_open = False + order.filled = 0 + order.remaining = 0 + if order.side == 'buy': + # Close trade due to buy timeout expiration. + open_trade_count -= 1 + open_trades[pair].remove(open_trade) + LocalTrade.trades_open.remove(open_trade) + # trades.append(trade_entry) # TODO: Needed or not? + elif order.side == 'sell': + # Close sell order and retry selling on next signal. + del open_trade.orders[open_trade.orders.index(order)] # without positionstacking, we can only have one open trade per pair. # max_open_trades must be respected From 9140679bf45e949ed0ef02d483a5154d38e416db Mon Sep 17 00:00:00 2001 From: Rokas Kupstys Date: Sat, 22 Jan 2022 15:11:33 +0200 Subject: [PATCH 04/26] Backtest order timeout continued. --- freqtrade/optimize/backtesting.py | 158 +++++++++++++++++------------- 1 file changed, 88 insertions(+), 70 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index d26fe1411..a251c75d8 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -63,6 +63,8 @@ class Backtesting: LoggingMixin.show_output = False self.config = config self.results: Dict[str, Any] = {} + self.trade_id_counter: int = 0 + self.order_id_counter: int = 0 config['dry_run'] = True self.run_ids: Dict[str, str] = {} @@ -409,7 +411,6 @@ class Backtesting: closerate = self._get_close_rate(sell_row, trade, sell, trade_dur) # call the custom exit price,with default value as previous closerate current_profit = trade.calc_profit_ratio(closerate) - order_closed = True if sell.sell_type in (SellType.SELL_SIGNAL, SellType.CUSTOM_SELL): # Custom exit pricing only for sell-signals closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, @@ -417,7 +418,6 @@ class Backtesting: pair=trade.pair, trade=trade, current_time=sell_row[DATE_IDX], proposed_rate=closerate, current_profit=current_profit) - order_closed = self._get_order_filled(closerate, sell_row) # Confirm trade exit: time_in_force = self.strategy.order_time_in_force['sell'] @@ -440,20 +440,26 @@ class Backtesting: ): trade.sell_reason = sell_row[EXIT_TAG_IDX] - trade.close(closerate, show_msg=False) + self.order_id_counter += 1 order = Order( - ft_is_open=order_closed, + id=self.order_id_counter, + ft_trade_id=trade.id, + order_date=sell_row[DATE_IDX].to_pydatetime(), + order_update_date=sell_row[DATE_IDX].to_pydatetime(), + ft_is_open=True, ft_pair=trade.pair, + order_id=str(self.order_id_counter), symbol=trade.pair, - ft_order_side="buy", - side="buy", - order_type="market", - status="closed", + ft_order_side="sell", + side="sell", + order_type=self.strategy.order_types['sell'], + status="open", price=closerate, average=closerate, amount=trade.amount, - filled=trade.amount, - cost=trade.amount * closerate + filled=0, + remaining=trade.amount, + cost=trade.amount * closerate, ) trade.orders.append(order) return trade @@ -494,10 +500,13 @@ class Backtesting: current_time = row[DATE_IDX].to_pydatetime() entry_tag = row[BUY_TAG_IDX] if len(row) >= BUY_TAG_IDX + 1 else None # let's call the custom entry price, using the open price as default price - propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, - default_retval=row[OPEN_IDX])( - pair=pair, current_time=current_time, - proposed_rate=row[OPEN_IDX], entry_tag=entry_tag) # default value is the open rate + order_type = self.strategy.order_types['buy'] + propose_rate = row[OPEN_IDX] + if order_type == 'limit': + propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, + default_retval=row[OPEN_IDX])( + pair=pair, current_time=current_time, + proposed_rate=row[OPEN_IDX], entry_tag=entry_tag) # default value is the open rate min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0 max_stake_amount = self.wallets.get_available_stake_amount() @@ -507,7 +516,7 @@ class Backtesting: try: stake_amount = self.wallets.get_trade_stake_amount(pair, None) except DependencyException: - return trade + return None stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, default_retval=stake_amount)( @@ -522,7 +531,6 @@ class Backtesting: # If not pos adjust, trade is None return trade - order_type = self.strategy.order_types['buy'] time_in_force = self.strategy.order_time_in_force['sell'] # Confirm trade entry: if not pos_adjust: @@ -533,16 +541,21 @@ class Backtesting: return None if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount): + self.order_id_counter += 1 amount = round(stake_amount / propose_rate, 8) if trade is None: # Enter trade + self.trade_id_counter += 1 trade = LocalTrade( + id=self.trade_id_counter, + open_order_id=self.order_id_counter, pair=pair, open_rate=propose_rate, open_rate_requested=propose_rate, open_date=current_time, stake_amount=stake_amount, amount=amount, + amount_requested=amount, fee_open=self.fee, fee_close=self.fee, is_open=True, @@ -554,29 +567,28 @@ class Backtesting: order_filled = self._get_order_filled(propose_rate, row) order = Order( - order_date=current_time, + id=self.order_id_counter, + ft_trade_id=trade.id, ft_is_open=not order_filled, ft_pair=trade.pair, + order_id=str(self.order_id_counter), symbol=trade.pair, ft_order_side="buy", side="buy", - order_type="market", - status="closed", + order_type=order_type, + status="open", order_date=current_time, order_filled_date=current_time, order_update_date=current_time, price=propose_rate, average=propose_rate, amount=amount, - filled=amount if order_filled else 0, - remaining=0 if order_filled else amount, - cost=stake_amount + trade.fee_open + filled=0, + remaining=amount, + cost=stake_amount + trade.fee_open, ) - if not order_filled: - trade.open_order_id = 'buy' trade.orders.append(order) - if pos_adjust: - trade.recalc_trade_from_orders() + trade.recalc_trade_from_orders() return trade @@ -589,6 +601,8 @@ class Backtesting: for pair in open_trades.keys(): if len(open_trades[pair]) > 0: for trade in open_trades[pair]: + if trade.open_order_id: + continue sell_row = data[pair][-1] trade.close_date = sell_row[DATE_IDX].to_pydatetime() @@ -638,7 +652,7 @@ class Backtesting: # Indexes per pair, so some pairs are allowed to have a missing start. indexes: Dict = defaultdict(int) - tmp = start_date + timedelta(minutes=self.timeframe_min) + current_time = start_date + timedelta(minutes=self.timeframe_min) open_trades: Dict[str, List[LocalTrade]] = defaultdict(list) open_trade_count = 0 @@ -647,7 +661,7 @@ class Backtesting: (end_date - start_date) / timedelta(minutes=self.timeframe_min))) # Loop timerange and get candle for each pair at that point in time - while tmp <= end_date: + while current_time <= end_date: open_trade_count_start = open_trade_count self.check_abort() for i, pair in enumerate(data): @@ -662,48 +676,21 @@ class Backtesting: continue # Waits until the time-counter reaches the start of the data for this pair. - if row[DATE_IDX] > tmp: + if row[DATE_IDX] > current_time: continue row_index += 1 indexes[pair] = row_index self.dataprovider._set_dataframe_max_index(row_index) - # Check order filling - for open_trade in list(open_trades[pair]): - # TODO: should open orders be stored in a separate list? - if open_trade.open_order_id: - order = open_trade.select_order(is_open=True) - if order is None: - continue - if self._get_order_filled(order.price, row): - open_trade.open_order_id = None - order.ft_is_open = False - order.filled = order.price - order.remaining = 0 - timeout = self.config['unfilledtimeout'].get(order.side, 0) - if 0 < timeout <= (tmp - order.order_date).seconds / 60: - open_trade.open_order_id = None - order.ft_is_open = False - order.filled = 0 - order.remaining = 0 - if order.side == 'buy': - # Close trade due to buy timeout expiration. - open_trade_count -= 1 - open_trades[pair].remove(open_trade) - LocalTrade.trades_open.remove(open_trade) - # trades.append(trade_entry) # TODO: Needed or not? - elif order.side == 'sell': - # Close sell order and retry selling on next signal. - del open_trade.orders[open_trade.orders.index(order)] - + # 1. Process buys. # without positionstacking, we can only have one open trade per pair. # max_open_trades must be respected # don't open on the last row if ( (position_stacking or len(open_trades[pair]) == 0) and self.trade_slot_available(max_open_trades, open_trade_count_start) - and tmp != end_date + and current_time != end_date and row[BUY_IDX] == 1 and row[SELL_IDX] != 1 and not PairLocks.is_pair_locked(pair, row[DATE_IDX]) @@ -717,29 +704,60 @@ class Backtesting: open_trade_count += 1 # logger.debug(f"{pair} - Emulate creation of new trade: {trade}.") open_trades[pair].append(trade) - LocalTrade.add_bt_trade(trade) for trade in list(open_trades[pair]): - # TODO: This could be avoided with a separate list - if trade.open_order_id: - continue - # also check the buying candle for sell conditions. - trade_entry = self._get_sell_trade_entry(trade, row) - # Sell occurred - if trade_entry: + # 2. Process buy orders. + order = trade.select_order('buy', is_open=True) + if order and self._get_order_filled(order.price, row): + order.order_filled_date = row[DATE_IDX] + trade.open_order_id = None + order.filled = order.amount + order.status = 'closed' + order.ft_is_open = False + LocalTrade.add_bt_trade(trade) + + # 3. Create sell orders (if any) + if not trade.open_order_id: + self._get_sell_trade_entry(trade, row) # Place sell order if necessary + + # 4. Process sell orders. + order = trade.select_order('sell', is_open=True) + if order and self._get_order_filled(order.price, row): + trade.open_order_id = None + order.order_filled_date = trade.close_date = row[DATE_IDX] + order.filled = order.amount + order.status = 'closed' + order.ft_is_open = False + trade.close(order.price, show_msg=False) + # logger.debug(f"{pair} - Backtesting sell {trade}") open_trade_count -= 1 open_trades[pair].remove(trade) - LocalTrade.close_bt_trade(trade) - trades.append(trade_entry) + trades.append(trade) if enable_protections: self.protections.stop_per_pair(pair, row[DATE_IDX]) - self.protections.global_stop(tmp) + self.protections.global_stop(current_time) + + # 5. Cancel expired buy/sell orders. + for order in [o for o in trade.orders if o.ft_is_open]: + timeout = self.config['unfilledtimeout'].get(order.side, 0) + if 0 < timeout <= (current_time - order.order_date).seconds / 60: + trade.open_order_id = None + order.ft_is_open = False + order.filled = 0 + order.remaining = 0 + if order.side == 'buy': + # Close trade due to buy timeout expiration. + open_trade_count -= 1 + open_trades[pair].remove(trade) + elif order.side == 'sell': + # Close sell order and retry selling on next signal. + del trade.orders[trade.orders.index(order)] # Move time one configured time_interval ahead. self.progress.increment() - tmp += timedelta(minutes=self.timeframe_min) + current_time += timedelta(minutes=self.timeframe_min) trades += self.handle_left_open(open_trades, data=data) self.wallets.update() From 49cecf1cb25d82d0610d104c3bd012cd42efa0b7 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 22 Jan 2022 15:08:54 +0100 Subject: [PATCH 05/26] Small cosmetic fix --- freqtrade/optimize/backtesting.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index a251c75d8..badc4111d 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -416,7 +416,7 @@ class Backtesting: closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, default_retval=closerate)( pair=trade.pair, trade=trade, - current_time=sell_row[DATE_IDX], + current_time=sell_candle_time, proposed_rate=closerate, current_profit=current_profit) # Confirm trade exit: @@ -444,8 +444,8 @@ class Backtesting: order = Order( id=self.order_id_counter, ft_trade_id=trade.id, - order_date=sell_row[DATE_IDX].to_pydatetime(), - order_update_date=sell_row[DATE_IDX].to_pydatetime(), + order_date=sell_candle_time, + order_update_date=sell_candle_time, ft_is_open=True, ft_pair=trade.pair, order_id=str(self.order_id_counter), From 44e616c2643ffee62784556181740ed275a05901 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 22 Jan 2022 15:44:33 +0100 Subject: [PATCH 06/26] Add unfilledtimeout to required props for backtesting --- freqtrade/constants.py | 1 + freqtrade/optimize/backtesting.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/freqtrade/constants.py b/freqtrade/constants.py index d94e8d850..d7ba0bf98 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -456,6 +456,7 @@ SCHEMA_BACKTEST_REQUIRED = [ 'dry_run_wallet', 'dataformat_ohlcv', 'dataformat_trades', + 'unfilledtimeout', ] SCHEMA_MINIMAL_REQUIRED = [ diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index badc4111d..ef0c6c833 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -564,12 +564,11 @@ class Backtesting: orders=[] ) trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) - order_filled = self._get_order_filled(propose_rate, row) order = Order( id=self.order_id_counter, ft_trade_id=trade.id, - ft_is_open=not order_filled, + ft_is_open=True, ft_pair=trade.pair, order_id=str(self.order_id_counter), symbol=trade.pair, From f4149ee46282abd4f8205be4dd74531c077c3438 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 30 Jan 2022 15:27:18 +0100 Subject: [PATCH 07/26] Force ROI to be within candle --- freqtrade/optimize/backtesting.py | 12 +++++++++--- tests/optimize/test_backtesting.py | 3 ++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index ef0c6c833..31cb20ee7 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -355,7 +355,10 @@ class Backtesting: # use Open rate if open_rate > calculated sell rate return sell_row[OPEN_IDX] - return close_rate + # Use the maximum between close_rate and low as we + # cannot sell outside of a candle. + # Applies when a new ROI setting comes in place and the whole candle is above that. + return min(max(close_rate, sell_row[LOW_IDX]), sell_row[HIGH_IDX]) else: # This should not be reached... @@ -384,7 +387,7 @@ class Backtesting: def _get_order_filled(self, rate: float, row: Tuple) -> bool: """ Rate is within candle, therefore filled""" - return row[LOW_IDX] < rate < row[HIGH_IDX] + return row[LOW_IDX] <= rate <= row[HIGH_IDX] def _get_sell_trade_entry_for_candle(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]: @@ -563,6 +566,9 @@ class Backtesting: exchange='backtesting', orders=[] ) + else: + trade.open_order_id = self.order_id_counter + trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) order = Order( @@ -697,7 +703,7 @@ class Backtesting: trade = self._enter_trade(pair, row) if trade: # TODO: hacky workaround to avoid opening > max_open_trades - # This emulates previous behaviour - not sure if this is correct + # This emulates previous behavior - not sure if this is correct # Prevents buying if the trade-slot was freed in this candle open_trade_count_start += 1 open_trade_count += 1 diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 3af431f87..84ffc1548 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -635,7 +635,8 @@ def test_backtest__get_sell_trade_entry(default_conf, fee, mocker) -> None: assert res.sell_reason == SellType.ROI.value # Sell at minute 3 (not available above!) assert res.close_date_utc == datetime(2020, 1, 1, 5, 3, tzinfo=timezone.utc) - assert round(res.close_rate, 3) == round(209.0225, 3) + sell_order = res.select_order('sell', True) + assert sell_order is not None def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None: From 090554f19784b4387837e1b6103acd2aebfff182 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 30 Jan 2022 15:41:05 +0100 Subject: [PATCH 08/26] Try fill backtest order imediately for adjusted order --- freqtrade/optimize/backtesting.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 31cb20ee7..ff139c674 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -566,8 +566,6 @@ class Backtesting: exchange='backtesting', orders=[] ) - else: - trade.open_order_id = self.order_id_counter trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) @@ -592,6 +590,12 @@ class Backtesting: remaining=amount, cost=stake_amount + trade.fee_open, ) + if pos_adjust and self._get_order_filled(order.price, row): + order.filled = order.amount + order.status = 'closed' + order.ft_is_open = False + else: + trade.open_order_id = self.order_id_counter trade.orders.append(order) trade.recalc_trade_from_orders() From 7ac44380f7ec67e86b2db2c06aa10a8746bb9b8e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 30 Jan 2022 15:44:13 +0100 Subject: [PATCH 09/26] Extract backtest order closing to models class --- freqtrade/optimize/backtesting.py | 14 +++----------- freqtrade/persistence/models.py | 6 ++++++ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index ff139c674..220d7557e 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -591,9 +591,7 @@ class Backtesting: cost=stake_amount + trade.fee_open, ) if pos_adjust and self._get_order_filled(order.price, row): - order.filled = order.amount - order.status = 'closed' - order.ft_is_open = False + order.close_bt_order(current_time) else: trade.open_order_id = self.order_id_counter trade.orders.append(order) @@ -718,11 +716,8 @@ class Backtesting: # 2. Process buy orders. order = trade.select_order('buy', is_open=True) if order and self._get_order_filled(order.price, row): - order.order_filled_date = row[DATE_IDX] + order.close_bt_order(current_time) trade.open_order_id = None - order.filled = order.amount - order.status = 'closed' - order.ft_is_open = False LocalTrade.add_bt_trade(trade) # 3. Create sell orders (if any) @@ -733,10 +728,7 @@ class Backtesting: order = trade.select_order('sell', is_open=True) if order and self._get_order_filled(order.price, row): trade.open_order_id = None - order.order_filled_date = trade.close_date = row[DATE_IDX] - order.filled = order.amount - order.status = 'closed' - order.ft_is_open = False + trade.close_date = current_time trade.close(order.price, show_msg=False) # logger.debug(f"{pair} - Backtesting sell {trade}") diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index ee7ad3fdd..6dd4ceaad 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -188,6 +188,12 @@ class Order(_DECL_BASE): 'status': self.status, } + def close_bt_order(self, close_date: datetime): + self.order_filled_date = close_date + self.filled = self.amount + self.status = 'closed' + self.ft_is_open = False + @staticmethod def update_orders(orders: List['Order'], order: Dict[str, Any]): """ From 6637dacd7f0556b6076f63f099fed6d9e9899de1 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 30 Jan 2022 17:17:03 +0100 Subject: [PATCH 10/26] Extract protections in backtesting --- 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 220d7557e..2a7602cd3 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -630,6 +630,11 @@ class Backtesting: self.rejected_trades += 1 return False + def run_protections(self, enable_protections, pair: str, current_time: datetime): + if enable_protections: + self.protections.stop_per_pair(pair, current_time) + self.protections.global_stop(current_time) + def backtest(self, processed: Dict, start_date: datetime, end_date: datetime, max_open_trades: int = 0, position_stacking: bool = False, @@ -736,9 +741,7 @@ class Backtesting: open_trades[pair].remove(trade) LocalTrade.close_bt_trade(trade) trades.append(trade) - if enable_protections: - self.protections.stop_per_pair(pair, row[DATE_IDX]) - self.protections.global_stop(current_time) + self.run_protections(enable_protections, pair, current_time) # 5. Cancel expired buy/sell orders. for order in [o for o in trade.orders if o.ft_is_open]: From 1e603985c5a6ca15cf2f08e20781b6c3955ef3af Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 30 Jan 2022 17:39:23 +0100 Subject: [PATCH 11/26] Extract backtesting order cancelling --- freqtrade/optimize/backtesting.py | 41 ++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 2a7602cd3..5793da02b 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -635,6 +635,28 @@ class Backtesting: self.protections.stop_per_pair(pair, current_time) self.protections.global_stop(current_time) + def check_order_cancel(self, trade: LocalTrade, current_time) -> bool: + """ + Check if an order has been canceled. + Returns True if the trade should be Deleted (initial order was canceled). + """ + for order in [o for o in trade.orders if o.ft_is_open]: + + timedout = self.strategy.ft_check_timed_out(order.side, trade, {}, current_time) + if timedout: + if order.side == 'buy': + if trade.nr_of_successful_buys == 0: + # Remove trade due to buy timeout expiration. + return True + else: + # Close additional buy order + del trade.orders[trade.orders.index(order)] + if order.side == 'sell': + # Close sell order and retry selling on next signal. + del trade.orders[trade.orders.index(order)] + + return False + def backtest(self, processed: Dict, start_date: datetime, end_date: datetime, max_open_trades: int = 0, position_stacking: bool = False, @@ -744,20 +766,11 @@ class Backtesting: self.run_protections(enable_protections, pair, current_time) # 5. Cancel expired buy/sell orders. - for order in [o for o in trade.orders if o.ft_is_open]: - timeout = self.config['unfilledtimeout'].get(order.side, 0) - if 0 < timeout <= (current_time - order.order_date).seconds / 60: - trade.open_order_id = None - order.ft_is_open = False - order.filled = 0 - order.remaining = 0 - if order.side == 'buy': - # Close trade due to buy timeout expiration. - open_trade_count -= 1 - open_trades[pair].remove(trade) - elif order.side == 'sell': - # Close sell order and retry selling on next signal. - del trade.orders[trade.orders.index(order)] + canceled = self.check_order_cancel(trade, current_time) + if canceled: + # Close trade due to buy timeout expiration. + open_trade_count -= 1 + open_trades[pair].remove(trade) # Move time one configured time_interval ahead. self.progress.increment() From 4ea79a32e4e2083cae89a5b0dd3315889b60d2d6 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 30 Jan 2022 17:47:37 +0100 Subject: [PATCH 12/26] Use Order object for ft_timeout check --- freqtrade/freqtradebot.py | 14 ++++++++------ freqtrade/optimize/backtesting.py | 4 ++-- freqtrade/persistence/models.py | 14 ++++++++++++++ freqtrade/strategy/interface.py | 10 +++++----- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 572ceeabf..279bb6161 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -987,18 +987,20 @@ class FreqtradeBot(LoggingMixin): fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order) + order_obj = trade.select_order_by_order_id(trade.open_order_id) + if (order['side'] == 'buy' and (order['status'] == 'open' or fully_cancelled) and ( fully_cancelled - or self.strategy.ft_check_timed_out( - 'buy', trade, order, datetime.now(timezone.utc)) - )): + or (order_obj and self.strategy.ft_check_timed_out( + 'buy', trade, order_obj, datetime.now(timezone.utc)) + ))): self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT']) elif (order['side'] == 'sell' and (order['status'] == 'open' or fully_cancelled) and ( fully_cancelled - or self.strategy.ft_check_timed_out( - 'sell', trade, order, datetime.now(timezone.utc))) - ): + or (order_obj and self.strategy.ft_check_timed_out( + 'sell', trade, order_obj, datetime.now(timezone.utc)) + ))): self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT']) canceled_count = trade.get_exit_order_count() max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 5793da02b..082fe0d5e 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -593,7 +593,7 @@ class Backtesting: if pos_adjust and self._get_order_filled(order.price, row): order.close_bt_order(current_time) else: - trade.open_order_id = self.order_id_counter + trade.open_order_id = str(self.order_id_counter) trade.orders.append(order) trade.recalc_trade_from_orders() @@ -642,7 +642,7 @@ class Backtesting: """ for order in [o for o in trade.orders if o.ft_is_open]: - timedout = self.strategy.ft_check_timed_out(order.side, trade, {}, current_time) + timedout = self.strategy.ft_check_timed_out(order.side, trade, order, current_time) if timedout: if order.side == 'buy': if trade.nr_of_successful_buys == 0: diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 6dd4ceaad..2e0f0753a 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -132,6 +132,10 @@ class Order(_DECL_BASE): order_filled_date = Column(DateTime, nullable=True) order_update_date = Column(DateTime, nullable=True) + @property + def order_date_utc(self): + return self.order_date.replace(tzinfo=timezone.utc) + def __repr__(self): return (f'Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, ' @@ -641,6 +645,16 @@ class LocalTrade(): if self.stop_loss_pct is not None and self.open_rate is not None: self.adjust_stop_loss(self.open_rate, self.stop_loss_pct) + def select_order_by_order_id(self, order_id: str) -> Optional[Order]: + """ + Finds order object by Order id. + :param order_id: Exchange order id + """ + orders = [o for o in self.orders if o.order_id == order_id] + if orders: + return orders[0] + return None + def select_order( self, order_side: str = None, is_open: Optional[bool] = None) -> Optional[Order]: """ diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 78dae6c5d..0bd7834e2 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -18,6 +18,7 @@ from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.exchange.exchange import timeframe_to_next_date from freqtrade.persistence import PairLocks, Trade +from freqtrade.persistence.models import LocalTrade, Order from freqtrade.strategy.hyper import HyperStrategyMixin from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators, _create_and_merge_informative_pair, @@ -862,23 +863,22 @@ class IStrategy(ABC, HyperStrategyMixin): else: return current_profit > roi - def ft_check_timed_out(self, side: str, trade: Trade, order: Dict, + def ft_check_timed_out(self, side: str, trade: LocalTrade, order: Order, current_time: datetime) -> bool: """ FT Internal method. Check if timeout is active, and if the order is still open and timed out """ timeout = self.config.get('unfilledtimeout', {}).get(side) - ordertime = arrow.get(order['datetime']).datetime if timeout is not None: timeout_unit = self.config.get('unfilledtimeout', {}).get('unit', 'minutes') timeout_kwargs = {timeout_unit: -timeout} timeout_threshold = current_time + timedelta(**timeout_kwargs) - timedout = (order['status'] == 'open' and order['side'] == side - and ordertime < timeout_threshold) + timedout = (order.status == 'open' and order.side == side + and order.order_date_utc < timeout_threshold) if timedout: return True - time_method = self.check_sell_timeout if order['side'] == 'sell' else self.check_buy_timeout + time_method = self.check_sell_timeout if order.side == 'sell' else self.check_buy_timeout return strategy_safe_wrapper(time_method, default_retval=False)( From e08006ea25e50be918686e03b036f3a4e0c1e45b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 30 Jan 2022 19:25:09 +0100 Subject: [PATCH 13/26] Adjust tests to use order Object --- tests/conftest.py | 24 ++++++++++++++++++++++-- tests/test_freqtradebot.py | 12 ++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 20e027c2e..185ed5dc2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ from freqtrade.edge import PairInfo from freqtrade.enums import RunMode from freqtrade.exchange import Exchange from freqtrade.freqtradebot import FreqtradeBot -from freqtrade.persistence import LocalTrade, Trade, init_db +from freqtrade.persistence import LocalTrade, Order, Trade, init_db from freqtrade.resolvers import ExchangeResolver from freqtrade.worker import Worker from tests.conftest_trades import (mock_trade_1, mock_trade_2, mock_trade_3, mock_trade_4, @@ -1982,7 +1982,7 @@ def import_fails() -> None: @pytest.fixture(scope="function") def open_trade(): - return Trade( + trade = Trade( pair='ETH/BTC', open_rate=0.00001099, exchange='binance', @@ -1994,6 +1994,26 @@ def open_trade(): open_date=arrow.utcnow().shift(minutes=-601).datetime, is_open=True ) + trade.orders = [ + Order( + ft_order_side='buy', + ft_pair=trade.pair, + ft_is_open=False, + order_id='123456789', + status="closed", + symbol=trade.pair, + order_type="market", + side="buy", + price=trade.open_rate, + average=trade.open_rate, + filled=trade.amount, + remaining=0, + cost=trade.open_rate * trade.amount, + order_date=trade.open_date, + order_filled_date=trade.open_date, + ) + ] + return trade @pytest.fixture(scope="function") diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 523696759..a84616516 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -2042,6 +2042,7 @@ def test_check_handle_timedout_buy_usercustom(default_conf_usdt, ticker_usdt, li def test_check_handle_timedout_buy(default_conf_usdt, ticker_usdt, limit_buy_order_old, open_trade, fee, mocker) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old['id'] = open_trade.open_order_id limit_buy_cancel = deepcopy(limit_buy_order_old) limit_buy_cancel['status'] = 'canceled' cancel_order_mock = MagicMock(return_value=limit_buy_cancel) @@ -2126,6 +2127,8 @@ def test_check_handle_timedout_buy_exception(default_conf_usdt, ticker_usdt, def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, limit_sell_order_old, mocker, open_trade, caplog) -> None: default_conf_usdt["unfilledtimeout"] = {"buy": 1440, "sell": 1440, "exit_timeout_count": 1} + limit_sell_order_old['id'] = open_trade.open_order_id + rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() patch_exchange(mocker) @@ -2174,7 +2177,7 @@ def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, l # 2nd canceled trade - Fail execute sell caplog.clear() - open_trade.open_order_id = 'order_id_2' + open_trade.open_order_id = limit_sell_order_old['id'] mocker.patch('freqtrade.persistence.Trade.get_exit_order_count', return_value=1) mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_trade_exit', side_effect=DependencyException) @@ -2185,7 +2188,7 @@ def test_check_handle_timedout_sell_usercustom(default_conf_usdt, ticker_usdt, l caplog.clear() # 2nd canceled trade ... - open_trade.open_order_id = 'order_id_2' + open_trade.open_order_id = limit_sell_order_old['id'] freqtrade.check_handle_timedout() assert log_has_re('Emergencyselling trade.*', caplog) assert et_mock.call_count == 1 @@ -2195,6 +2198,7 @@ def test_check_handle_timedout_sell(default_conf_usdt, ticker_usdt, limit_sell_o open_trade) -> None: rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() + limit_sell_order_old['id'] = open_trade.open_order_id patch_exchange(mocker) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -2253,6 +2257,7 @@ def test_check_handle_cancelled_sell(default_conf_usdt, ticker_usdt, limit_sell_ def test_check_handle_timedout_partial(default_conf_usdt, ticker_usdt, limit_buy_order_old_partial, open_trade, mocker) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old_partial['id'] = open_trade.open_order_id limit_buy_canceled = deepcopy(limit_buy_order_old_partial) limit_buy_canceled['status'] = 'canceled' @@ -2283,6 +2288,7 @@ def test_check_handle_timedout_partial_fee(default_conf_usdt, ticker_usdt, open_ limit_buy_order_old_partial, trades_for_order, limit_buy_order_old_partial_canceled, mocker) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old_partial['id'] = open_trade.open_order_id cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial_canceled) mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=0)) patch_exchange(mocker) @@ -2322,6 +2328,8 @@ def test_check_handle_timedout_partial_except(default_conf_usdt, ticker_usdt, op fee, limit_buy_order_old_partial, trades_for_order, limit_buy_order_old_partial_canceled, mocker) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old_partial_canceled['id'] = open_trade.open_order_id + limit_buy_order_old_partial['id'] = open_trade.open_order_id cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial_canceled) patch_exchange(mocker) mocker.patch.multiple( From 58fad72778cc9f4e11e766d3a02ffe48bafeb6dc Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 30 Jan 2022 19:35:46 +0100 Subject: [PATCH 14/26] Update wallets when necessary closes #6321 --- freqtrade/optimize/backtesting.py | 6 +++++- freqtrade/wallets.py | 5 +++-- tests/optimize/test_backtesting.py | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 082fe0d5e..945022b66 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -381,6 +381,7 @@ class Backtesting: if stake_amount is not None and stake_amount > 0.0: pos_trade = self._enter_trade(trade.pair, row, stake_amount, trade) if pos_trade is not None: + self.wallets.update() return pos_trade return trade @@ -517,7 +518,7 @@ class Backtesting: pos_adjust = trade is not None if not pos_adjust: try: - stake_amount = self.wallets.get_trade_stake_amount(pair, None) + stake_amount = self.wallets.get_trade_stake_amount(pair, None, update=False) except DependencyException: return None @@ -746,6 +747,7 @@ class Backtesting: order.close_bt_order(current_time) trade.open_order_id = None LocalTrade.add_bt_trade(trade) + self.wallets.update() # 3. Create sell orders (if any) if not trade.open_order_id: @@ -763,6 +765,7 @@ class Backtesting: open_trades[pair].remove(trade) LocalTrade.close_bt_trade(trade) trades.append(trade) + self.wallets.update() self.run_protections(enable_protections, pair, current_time) # 5. Cancel expired buy/sell orders. @@ -771,6 +774,7 @@ class Backtesting: # Close trade due to buy timeout expiration. open_trade_count -= 1 open_trades[pair].remove(trade) + self.wallets.update() # Move time one configured time_interval ahead. self.progress.increment() diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index e57739595..93f3d3800 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -211,7 +211,7 @@ class Wallets: return stake_amount - def get_trade_stake_amount(self, pair: str, edge=None) -> float: + def get_trade_stake_amount(self, pair: str, edge=None, update: bool = True) -> float: """ Calculate stake amount for the trade :return: float: Stake amount @@ -219,7 +219,8 @@ class Wallets: """ stake_amount: float # Ensure wallets are uptodate. - self.update() + if update: + self.update() val_tied_up = Trade.total_open_trades_stakes() available_amount = self.get_available_stake_amount() diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 84ffc1548..649a43b32 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -521,6 +521,7 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None: # Fake 2 trades, so there's not enough amount for the next trade left. LocalTrade.trades_open.append(trade) LocalTrade.trades_open.append(trade) + backtesting.wallets.update() trade = backtesting._enter_trade(pair, row=row) assert trade is None LocalTrade.trades_open.pop() @@ -528,6 +529,7 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None: assert trade is not None backtesting.strategy.custom_stake_amount = lambda **kwargs: 123.5 + backtesting.wallets.update() trade = backtesting._enter_trade(pair, row=row) assert trade assert trade.stake_amount == 123.5 From 9bf86bbe273668d9a8a6f72303a4533bd090534b Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 30 Jan 2022 20:00:11 +0100 Subject: [PATCH 15/26] Extract backtesting row validation to separate function --- freqtrade/optimize/backtesting.py | 32 ++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 945022b66..4da8390af 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -658,6 +658,22 @@ class Backtesting: return False + def validate_row( + self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]: + try: + # Row is treated as "current incomplete candle". + # Buy / sell signals are shifted by 1 to compensate for this. + row = data[pair][row_index] + except IndexError: + # missing Data for one pair at the end. + # Warnings for this are shown during data loading + return None + + # Waits until the time-counter reaches the start of the data for this pair. + if row[DATE_IDX] > current_time: + return None + return row + def backtest(self, processed: Dict, start_date: datetime, end_date: datetime, max_open_trades: int = 0, position_stacking: bool = False, @@ -701,17 +717,8 @@ class Backtesting: self.check_abort() for i, pair in enumerate(data): row_index = indexes[pair] - try: - # Row is treated as "current incomplete candle". - # Buy / sell signals are shifted by 1 to compensate for this. - row = data[pair][row_index] - except IndexError: - # missing Data for one pair at the end. - # Warnings for this are shown during data loading - continue - - # Waits until the time-counter reaches the start of the data for this pair. - if row[DATE_IDX] > current_time: + row = self.validate_row(data, pair, row_index, current_time) + if not row: continue row_index += 1 @@ -769,8 +776,7 @@ class Backtesting: self.run_protections(enable_protections, pair, current_time) # 5. Cancel expired buy/sell orders. - canceled = self.check_order_cancel(trade, current_time) - if canceled: + if self.check_order_cancel(trade, current_time): # Close trade due to buy timeout expiration. open_trade_count -= 1 open_trades[pair].remove(trade) From 808cefe526aed74f355d2a7c4ed8b55e6fe96eb0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 5 Feb 2022 10:12:03 +0100 Subject: [PATCH 16/26] Update order_selection logic --- freqtrade/persistence/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 2e0f0753a..dfa98d97f 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -650,9 +650,9 @@ class LocalTrade(): Finds order object by Order id. :param order_id: Exchange order id """ - orders = [o for o in self.orders if o.order_id == order_id] - if orders: - return orders[0] + for o in self.orders: + if o.order_id == order_id: + return o return None def select_order( From 2a59ef7311d07e0fcc971432564e218f8489a60e Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 5 Feb 2022 15:12:29 +0100 Subject: [PATCH 17/26] Add detail tests for timeout behaviour --- tests/optimize/__init__.py | 1 + tests/optimize/test_backtest_detail.py | 47 ++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/tests/optimize/__init__.py b/tests/optimize/__init__.py index 68088d2d5..b5f14056c 100644 --- a/tests/optimize/__init__.py +++ b/tests/optimize/__init__.py @@ -36,6 +36,7 @@ class BTContainer(NamedTuple): trailing_stop_positive_offset: float = 0.0 use_sell_signal: bool = False use_custom_stoploss: bool = False + custom_entry_price: Optional[float] = None def _get_frame_time_from_offset(offset): diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index f41b6101c..06517fff8 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -1,5 +1,6 @@ # pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, C0330, unused-argument import logging +from unittest.mock import MagicMock import pytest @@ -534,6 +535,47 @@ tc33 = BTContainer(data=[ )] ) +# Test 34: Custom-entry-price below all candles should timeout - so no trade happens. +tc34 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # timeout + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.0, + custom_entry_price=4200, trades=[] +) + +# Test 35: Custom-entry-price above all candles should timeout - so no trade happens. +tc35 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # Timeout + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.0, + custom_entry_price=7200, trades=[] +) + +# Test 36: Custom-entry-price around candle low +# Causes immediate ROI exit. This is currently expected behavior (#6261) +# https://github.com/freqtrade/freqtrade/issues/6261 +# But may change at a later point. +tc36 = BTContainer(data=[ + # D O H L C V B S BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # enter trade (signal on last candle) and stop + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.1, + custom_entry_price=4952, + trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=1)] +) + + TESTS = [ tc0, tc1, @@ -569,6 +611,9 @@ TESTS = [ tc31, tc32, tc33, + tc34, + tc35, + tc36, ] @@ -597,6 +642,8 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None: backtesting.required_startup = 0 backtesting.strategy.advise_buy = lambda a, m: frame backtesting.strategy.advise_sell = lambda a, m: frame + if data.custom_entry_price: + backtesting.strategy.custom_entry_price = MagicMock(return_value=data.custom_entry_price) backtesting.strategy.use_custom_stoploss = data.use_custom_stoploss caplog.set_level(logging.DEBUG) From 22173851d6b8e8aa22d7adb8877e0e59775b6e2c Mon Sep 17 00:00:00 2001 From: Matthias Date: Sat, 5 Feb 2022 15:20:05 +0100 Subject: [PATCH 18/26] Detail tests for custom exit pricing --- tests/optimize/__init__.py | 1 + tests/optimize/test_backtest_detail.py | 37 +++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/optimize/__init__.py b/tests/optimize/__init__.py index b5f14056c..ce6ea0f0c 100644 --- a/tests/optimize/__init__.py +++ b/tests/optimize/__init__.py @@ -37,6 +37,7 @@ class BTContainer(NamedTuple): use_sell_signal: bool = False use_custom_stoploss: bool = False custom_entry_price: Optional[float] = None + custom_exit_price: Optional[float] = None def _get_frame_time_from_offset(offset): diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index 06517fff8..42d68593a 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -566,7 +566,7 @@ tc35 = BTContainer(data=[ tc36 = BTContainer(data=[ # D O H L C V B S BT [0, 5000, 5050, 4950, 5000, 6172, 1, 0], - [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # enter trade (signal on last candle) and stop + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # Enter and immediate ROI [2, 4900, 5250, 4500, 5100, 6172, 0, 0], [3, 5100, 5100, 4650, 4750, 6172, 0, 0], [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], @@ -576,6 +576,37 @@ tc36 = BTContainer(data=[ ) +# Test 37: Custom exit price below all candles +# causes sell signal timeout +tc37 = BTContainer(data=[ + # D O H L C V B S BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], + [2, 4900, 5250, 4900, 5100, 6172, 0, 1], # exit - but timeout + [3, 5100, 5100, 4950, 4950, 6172, 0, 0], + [4, 5000, 5100, 4950, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10}, profit_perc=0.0, + use_sell_signal=True, + custom_exit_price=4552, + trades=[BTrade(sell_reason=SellType.FORCE_SELL, open_tick=1, close_tick=4)] +) + +# Test 38: Custom exit price above all candles +# causes sell signal timeout +tc38 = BTContainer(data=[ + # D O H L C V B S BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], + [2, 4900, 5250, 4900, 5100, 6172, 0, 1], # exit - but timeout + [3, 5100, 5100, 4950, 4950, 6172, 0, 0], + [4, 5000, 5100, 4950, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10}, profit_perc=0.0, + use_sell_signal=True, + custom_exit_price=6052, + trades=[BTrade(sell_reason=SellType.FORCE_SELL, open_tick=1, close_tick=4)] +) + + TESTS = [ tc0, tc1, @@ -614,6 +645,8 @@ TESTS = [ tc34, tc35, tc36, + tc37, + tc38, ] @@ -644,6 +677,8 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None: backtesting.strategy.advise_sell = lambda a, m: frame if data.custom_entry_price: backtesting.strategy.custom_entry_price = MagicMock(return_value=data.custom_entry_price) + if data.custom_exit_price: + backtesting.strategy.custom_exit_price = MagicMock(return_value=data.custom_exit_price) backtesting.strategy.use_custom_stoploss = data.use_custom_stoploss caplog.set_level(logging.DEBUG) From 17d748dd4c3156e353fb4cafea2ff7163accaa54 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 6 Feb 2022 13:19:00 +0100 Subject: [PATCH 19/26] Improve handling of left_open_trades --- freqtrade/optimize/backtesting.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 4da8390af..737cede9d 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -609,7 +609,8 @@ class Backtesting: for pair in open_trades.keys(): if len(open_trades[pair]) > 0: for trade in open_trades[pair]: - if trade.open_order_id: + if trade.open_order_id and trade.nr_of_successful_buys == 0: + # Ignore trade if buy-order did not fill yet continue sell_row = data[pair][-1] From 644442e2f9eadede920eea40051096950e1fa287 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 6 Feb 2022 13:37:31 +0100 Subject: [PATCH 20/26] Track timedout orders --- docs/bot-basics.md | 1 + docs/strategy-callbacks.md | 6 ++++-- freqtrade/optimize/backtesting.py | 4 ++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/bot-basics.md b/docs/bot-basics.md index a9a2628f6..8c6303063 100644 --- a/docs/bot-basics.md +++ b/docs/bot-basics.md @@ -62,6 +62,7 @@ This loop will be repeated again and again until the bot is stopped. * Check position adjustments for open trades if enabled and call `adjust_trade_position()` to determine if an additional order is requested. * Call `custom_stoploss()` and `custom_sell()` to find custom exit points. * For sells based on sell-signal and custom-sell: Call `custom_exit_price()` to determine exit price (Prices are moved to be within the closing candle). + * Check for Order timeouts, either via the `unfilledtimeout` configuration, or via `check_buy_timeout()` / `check_sell_timeout()` strategy callbacks. * Generate backtest report output !!! Note diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index bff5bd998..9cdcb65bd 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -467,7 +467,8 @@ class AwesomeStrategy(IStrategy): 'sell': 60 * 25 } - def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + def check_buy_timeout(self, pair: str, trade: Trade, order: dict, + current_time: datetime, **kwargs) -> bool: ob = self.dp.orderbook(pair, 1) current_price = ob['bids'][0][0] # Cancel buy order if price is more than 2% above the order. @@ -476,7 +477,8 @@ class AwesomeStrategy(IStrategy): return False - def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + def check_sell_timeout(self, pair: str, trade: Trade, order: dict, + current_time: datetime, **kwargs) -> bool: ob = self.dp.orderbook(pair, 1) current_price = ob['asks'][0][0] # Cancel sell order if price is more than 2% below the order. diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 737cede9d..cc4eb5351 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -233,6 +233,7 @@ class Backtesting: PairLocks.reset_locks() Trade.reset_trades() self.rejected_trades = 0 + self.timedout_orders = 0 self.dataprovider.clear_cache() if enable_protections: self._load_protections(self.strategy) @@ -646,6 +647,7 @@ class Backtesting: timedout = self.strategy.ft_check_timed_out(order.side, trade, order, current_time) if timedout: + self.timedout_orders += 1 if order.side == 'buy': if trade.nr_of_successful_buys == 0: # Remove trade due to buy timeout expiration. @@ -796,6 +798,8 @@ class Backtesting: 'config': self.strategy.config, 'locks': PairLocks.get_all_locks(), 'rejected_signals': self.rejected_trades, + # TODO: timedout_orders should be shown as part of results. + # 'timedout_orders': self.timedout_orders, 'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']), } From 7232324eb7e4cc3e1fb98f2bcf2f27fbb65df6d2 Mon Sep 17 00:00:00 2001 From: Matthias Date: Sun, 6 Feb 2022 14:33:31 +0100 Subject: [PATCH 21/26] Update missing doc segment --- docs/strategy-callbacks.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 9cdcb65bd..24b81d2dc 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -389,8 +389,8 @@ class AwesomeStrategy(IStrategy): If the new_entryprice is 97, the proposed_rate is 100 and the `custom_price_max_distance_ratio` is set to 2%, The retained valid custom entry price will be 98, which is 2% below the current (proposed) rate. !!! Warning "Backtesting" - While Custom prices are supported in backtesting (starting with 2021.12), prices will be moved to within the candle's high/low prices. - This behavior is currently being tested, and might be changed at a later point. + Custom prices are supported in backtesting (starting with 2021.12), and orders will fill if the price falls within the candle's low/high range. + Orders that don't fill immediately are subject to regular timeout handling. `custom_exit_price()` is only called for sells of type Sell_signal and Custom sell. All other sell-types will use regular backtesting prices. ## Custom order timeout rules @@ -400,7 +400,8 @@ Simple, time-based order-timeouts can be configured either via strategy or in th However, freqtrade also offers a custom callback for both order types, which allows you to decide based on custom criteria if an order did time out or not. !!! Note - Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances. + Backtesting fills orders if their price falls within the candle's low/high range. + The below callbacks will be called for orders that don't fill automatically (which use custom pricing). ### Custom order timeout example From 036c2888b45b5c8695cf9cab9551cdf604d6d7eb Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 7 Feb 2022 18:49:30 +0100 Subject: [PATCH 22/26] Track timedout entry/exit orders --- docs/backtesting.md | 3 +++ freqtrade/optimize/backtesting.py | 10 ++++++---- freqtrade/optimize/optimize_reports.py | 5 +++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/docs/backtesting.md b/docs/backtesting.md index 7420c1dec..e7846b1f8 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -313,6 +313,7 @@ A backtesting result will look like that: | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | | Rejected Buy signals | 3089 | +| Entry/Exit Timeouts | 0 / 0 | | | | | Min balance | 0.00945123 BTC | | Max balance | 0.01846651 BTC | @@ -400,6 +401,7 @@ It contains some useful key metrics about performance of your strategy on backte | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | | Rejected Buy signals | 3089 | +| Entry/Exit Timeouts | 0 / 0 | | | | | Min balance | 0.00945123 BTC | | Max balance | 0.01846651 BTC | @@ -429,6 +431,7 @@ It contains some useful key metrics about performance of your strategy on backte - `Days win/draw/lose`: Winning / Losing days (draws are usually days without closed trade). - `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades. - `Rejected Buy signals`: Buy signals that could not be acted upon due to max_open_trades being reached. +- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used). - `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period. - `Drawdown (Account)`: Maximum Account Drawdown experienced. Calculated as $(Absolute Drawdown) / (DrawdownHigh + startingBalance)$. - `Drawdown`: Maximum, absolute drawdown experienced. Difference between Drawdown High and Subsequent Low point. diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index cc4eb5351..a06c1fee8 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -233,7 +233,8 @@ class Backtesting: PairLocks.reset_locks() Trade.reset_trades() self.rejected_trades = 0 - self.timedout_orders = 0 + self.timedout_entry_orders = 0 + self.timedout_exit_orders = 0 self.dataprovider.clear_cache() if enable_protections: self._load_protections(self.strategy) @@ -647,8 +648,8 @@ class Backtesting: timedout = self.strategy.ft_check_timed_out(order.side, trade, order, current_time) if timedout: - self.timedout_orders += 1 if order.side == 'buy': + self.timedout_entry_orders += 1 if trade.nr_of_successful_buys == 0: # Remove trade due to buy timeout expiration. return True @@ -656,6 +657,7 @@ class Backtesting: # Close additional buy order del trade.orders[trade.orders.index(order)] if order.side == 'sell': + self.timedout_exit_orders += 1 # Close sell order and retry selling on next signal. del trade.orders[trade.orders.index(order)] @@ -798,8 +800,8 @@ class Backtesting: 'config': self.strategy.config, 'locks': PairLocks.get_all_locks(), 'rejected_signals': self.rejected_trades, - # TODO: timedout_orders should be shown as part of results. - # 'timedout_orders': self.timedout_orders, + 'timedout_entry_orders': self.timedout_entry_orders, + 'timedout_exit_orders': self.timedout_exit_orders, 'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']), } diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index 859238af3..5b1c2e135 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -436,6 +436,8 @@ def generate_strategy_stats(pairlist: List[str], 'dry_run_wallet': starting_balance, 'final_balance': content['final_balance'], 'rejected_signals': content['rejected_signals'], + 'timedout_entry_orders': content['timedout_entry_orders'], + 'timedout_exit_orders': content['timedout_exit_orders'], 'max_open_trades': max_open_trades, 'max_open_trades_setting': (config['max_open_trades'] if config['max_open_trades'] != float('inf') else -1), @@ -726,6 +728,9 @@ def text_table_add_metrics(strat_results: Dict) -> str: ('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"), ('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"), ('Rejected Buy signals', strat_results.get('rejected_signals', 'N/A')), + ('Entry/Exit Timeouts', + f"{strat_results.get('timedout_entry_orders', 'N/A')} / " + f"{strat_results.get('timedout_exit_orders', 'N/A')}"), ('', ''), # Empty line to improve readability ('Min balance', round_coin_value(strat_results['csum_min'], From 85767d0d70e8a3683e370b4c54e6d701a0fa4912 Mon Sep 17 00:00:00 2001 From: Matthias Date: Mon, 7 Feb 2022 19:33:22 +0100 Subject: [PATCH 23/26] Add timedout_*_orders to tests --- tests/optimize/test_backtesting.py | 12 ++++++++++++ tests/optimize/test_hyperopt.py | 4 ++++ tests/optimize/test_optimize_reports.py | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 649a43b32..d61dffac4 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -1023,6 +1023,8 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }) mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', @@ -1131,6 +1133,8 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }, { @@ -1138,6 +1142,8 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } ]) @@ -1240,6 +1246,8 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker, 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }, { @@ -1247,6 +1255,8 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker, 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } ]) @@ -1308,6 +1318,8 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }) mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index 1f7c2ee8c..2328585dd 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -364,6 +364,8 @@ def test_hyperopt_format_results(hyperopt): 'locks': [], 'final_balance': 0.02, 'rejected_signals': 2, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'backtest_start_time': 1619718665, 'backtest_end_time': 1619718665, } @@ -431,6 +433,8 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None: 'config': hyperopt_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 68257f4d8..c8768e236 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -82,6 +82,8 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): 'locks': [], 'final_balance': 1000.02, 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'backtest_start_time': Arrow.utcnow().int_timestamp, 'backtest_end_time': Arrow.utcnow().int_timestamp, 'run_id': '123', @@ -131,6 +133,8 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): 'locks': [], 'final_balance': 1000.02, 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'backtest_start_time': Arrow.utcnow().int_timestamp, 'backtest_end_time': Arrow.utcnow().int_timestamp, 'run_id': '124', From d2dbe8f8d02c397e993b49a9a2a57c9f0b72e1a4 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 8 Feb 2022 06:47:55 +0100 Subject: [PATCH 24/26] Improve doc wording --- docs/strategy-callbacks.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 24b81d2dc..555352d21 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -390,7 +390,7 @@ class AwesomeStrategy(IStrategy): !!! Warning "Backtesting" Custom prices are supported in backtesting (starting with 2021.12), and orders will fill if the price falls within the candle's low/high range. - Orders that don't fill immediately are subject to regular timeout handling. + Orders that don't fill immediately are subject to regular timeout handling, which happens once per (detail) candle. `custom_exit_price()` is only called for sells of type Sell_signal and Custom sell. All other sell-types will use regular backtesting prices. ## Custom order timeout rules @@ -401,7 +401,7 @@ However, freqtrade also offers a custom callback for both order types, which all !!! Note Backtesting fills orders if their price falls within the candle's low/high range. - The below callbacks will be called for orders that don't fill automatically (which use custom pricing). + The below callbacks will be called once per (detail) candle for orders that don't fill immediately (which use custom pricing). ### Custom order timeout example From b192c82731105ad02da5e61e4451a1aff9e399d3 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 8 Feb 2022 07:10:54 +0100 Subject: [PATCH 25/26] Only call "custom_exit_price" for limit orders --- freqtrade/optimize/backtesting.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index a06c1fee8..6bf0a7270 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -417,16 +417,19 @@ class Backtesting: closerate = self._get_close_rate(sell_row, trade, sell, trade_dur) # call the custom exit price,with default value as previous closerate current_profit = trade.calc_profit_ratio(closerate) + order_type = self.strategy.order_types['sell'] if sell.sell_type in (SellType.SELL_SIGNAL, SellType.CUSTOM_SELL): # Custom exit pricing only for sell-signals - closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, - default_retval=closerate)( - pair=trade.pair, trade=trade, - current_time=sell_candle_time, - proposed_rate=closerate, current_profit=current_profit) + if order_type == 'limit': + closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, + default_retval=closerate)( + pair=trade.pair, trade=trade, + current_time=sell_candle_time, + proposed_rate=closerate, current_profit=current_profit) # Confirm trade exit: time_in_force = self.strategy.order_time_in_force['sell'] + if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount, rate=closerate, @@ -458,7 +461,7 @@ class Backtesting: symbol=trade.pair, ft_order_side="sell", side="sell", - order_type=self.strategy.order_types['sell'], + order_type=order_type, status="open", price=closerate, average=closerate, @@ -537,7 +540,7 @@ class Backtesting: # If not pos adjust, trade is None return trade - time_in_force = self.strategy.order_time_in_force['sell'] + time_in_force = self.strategy.order_time_in_force['buy'] # Confirm trade entry: if not pos_adjust: if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( From dcf8ad36f9ccb04ee725eaf65e83a7720a5fe315 Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 8 Feb 2022 19:10:29 +0100 Subject: [PATCH 26/26] Backtesting should not allow unrealistic (automatic-filling) orders. --- freqtrade/optimize/backtesting.py | 9 +++++++-- tests/optimize/test_backtest_detail.py | 14 ++++++++------ 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 6bf0a7270..6c5933a51 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -426,7 +426,9 @@ class Backtesting: pair=trade.pair, trade=trade, current_time=sell_candle_time, proposed_rate=closerate, current_profit=current_profit) - + # We can't place orders lower than current low. + # freqtrade does not support this in live, and the order would fill immediately + closerate = max(closerate, sell_row[LOW_IDX]) # Confirm trade exit: time_in_force = self.strategy.order_time_in_force['sell'] @@ -515,7 +517,10 @@ class Backtesting: propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, default_retval=row[OPEN_IDX])( pair=pair, current_time=current_time, - proposed_rate=row[OPEN_IDX], entry_tag=entry_tag) # default value is the open rate + proposed_rate=propose_rate, entry_tag=entry_tag) # default value is the open rate + # We can't place orders higher than current high (otherwise it'd be a stop limit buy) + # which freqtrade does not support in live. + propose_rate = min(propose_rate, row[HIGH_IDX]) min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0 max_stake_amount = self.wallets.get_available_stake_amount() diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index 42d68593a..3164e11b9 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -547,7 +547,7 @@ tc34 = BTContainer(data=[ custom_entry_price=4200, trades=[] ) -# Test 35: Custom-entry-price above all candles should timeout - so no trade happens. +# Test 35: Custom-entry-price above all candles should have rate adjusted to "entry candle high" tc35 = BTContainer(data=[ # D O H L C V B S [0, 5000, 5050, 4950, 5000, 6172, 1, 0], @@ -555,8 +555,10 @@ tc35 = BTContainer(data=[ [2, 4900, 5250, 4500, 5100, 6172, 0, 0], [3, 5100, 5100, 4650, 4750, 6172, 0, 0], [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], - stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.0, - custom_entry_price=7200, trades=[] + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=-0.01, + custom_entry_price=7200, trades=[ + BTrade(sell_reason=SellType.STOP_LOSS, open_tick=1, close_tick=1) + ] ) # Test 36: Custom-entry-price around candle low @@ -577,7 +579,7 @@ tc36 = BTContainer(data=[ # Test 37: Custom exit price below all candles -# causes sell signal timeout +# Price adjusted to candle Low. tc37 = BTContainer(data=[ # D O H L C V B S BT [0, 5000, 5050, 4950, 5000, 6172, 1, 0], @@ -585,10 +587,10 @@ tc37 = BTContainer(data=[ [2, 4900, 5250, 4900, 5100, 6172, 0, 1], # exit - but timeout [3, 5100, 5100, 4950, 4950, 6172, 0, 0], [4, 5000, 5100, 4950, 4950, 6172, 0, 0]], - stop_loss=-0.10, roi={"0": 0.10}, profit_perc=0.0, + stop_loss=-0.10, roi={"0": 0.10}, profit_perc=-0.01, use_sell_signal=True, custom_exit_price=4552, - trades=[BTrade(sell_reason=SellType.FORCE_SELL, open_tick=1, close_tick=4)] + trades=[BTrade(sell_reason=SellType.SELL_SIGNAL, open_tick=1, close_tick=3)] ) # Test 38: Custom exit price above all candles